CaIon 1 год назад
Родитель
Сommit
962dc984f4
55 измененных файлов с 5246 добавлено и 3572 удалено
  1. 1 1
      web/README.md
  2. 1 1
      web/index.html
  3. 23 25
      web/src/App.js
  4. 336 203
      web/src/components/ChannelsTable.js
  5. 16 7
      web/src/components/Footer.js
  6. 1 1
      web/src/components/GitHubOAuth.js
  7. 43 22
      web/src/components/HeaderBar.js
  8. 3 1
      web/src/components/Loading.js
  9. 103 36
      web/src/components/LoginForm.js
  10. 435 182
      web/src/components/LogsTable.js
  11. 294 127
      web/src/components/MjLogsTable.js
  12. 219 178
      web/src/components/OperationSetting.js
  13. 68 31
      web/src/components/OtherSetting.js
  14. 17 17
      web/src/components/PasswordResetConfirm.js
  15. 13 13
      web/src/components/PasswordResetForm.js
  16. 234 123
      web/src/components/PersonalSetting.js
  17. 2 3
      web/src/components/PrivateRoute.js
  18. 177 132
      web/src/components/RedemptionsTable.js
  19. 41 33
      web/src/components/RegisterForm.js
  20. 133 97
      web/src/components/SiderBar.js
  21. 202 161
      web/src/components/SystemSetting.js
  22. 276 162
      web/src/components/TokensTable.js
  23. 276 150
      web/src/components/UsersTable.js
  24. 21 9
      web/src/components/WeChatIcon.js
  25. 2 2
      web/src/components/utils.js
  26. 98 20
      web/src/constants/channel.constants.js
  27. 1 1
      web/src/constants/index.js
  28. 1 1
      web/src/constants/toast.constants.js
  29. 14 14
      web/src/constants/user.constants.js
  30. 1 1
      web/src/context/Status/index.js
  31. 9 9
      web/src/context/User/index.js
  32. 4 4
      web/src/context/User/reducer.js
  33. 4 2
      web/src/helpers/api.js
  34. 8 8
      web/src/helpers/auth-header.js
  35. 1 1
      web/src/helpers/history.js
  36. 1 1
      web/src/helpers/index.js
  37. 162 135
      web/src/helpers/render.js
  38. 9 20
      web/src/helpers/utils.js
  39. 59 55
      web/src/index.css
  40. 36 37
      web/src/index.js
  41. 19 14
      web/src/pages/About/index.js
  42. 708 606
      web/src/pages/Channel/EditChannel.js
  43. 11 11
      web/src/pages/Channel/index.js
  44. 0 1
      web/src/pages/Chat/index.js
  45. 396 337
      web/src/pages/Detail/index.js
  46. 108 71
      web/src/pages/Home/index.js
  47. 59 30
      web/src/pages/Redemption/EditRedemption.js
  48. 9 9
      web/src/pages/Redemption/index.js
  49. 41 41
      web/src/pages/Setting/index.js
  50. 130 68
      web/src/pages/Token/EditToken.js
  51. 3 3
      web/src/pages/Token/index.js
  52. 314 290
      web/src/pages/TopUp/index.js
  53. 24 15
      web/src/pages/User/AddUser.js
  54. 72 43
      web/src/pages/User/EditUser.js
  55. 7 7
      web/src/pages/User/index.js

+ 1 - 1
web/README.md

@@ -18,4 +18,4 @@ Before you start editing, make sure your `Actions on Save` options have `Optimiz
 ## Reference
 
 1. https://github.com/OIerDb-ng/OIerDb
-2. https://github.com/cornflourblue/react-hooks-redux-registration-login-example
+2. https://github.com/cornflourblue/react-hooks-redux-registration-login-example

+ 1 - 1
web/index.html

@@ -1,4 +1,4 @@
-<!DOCTYPE html>
+<!doctype html>
 <html lang="en">
   <head>
     <meta charset="utf-8" />

+ 23 - 25
web/src/App.js

@@ -48,7 +48,7 @@ function App() {
     }
     let logo = getLogo();
     if (logo) {
-      let linkElement = document.querySelector('link[rel~=\'icon\']');
+      let linkElement = document.querySelector("link[rel~='icon']");
       if (linkElement) {
         linkElement.href = logo;
       }
@@ -60,7 +60,7 @@ function App() {
       <Layout.Content>
         <Routes>
           <Route
-            path="/"
+            path='/'
             element={
               <Suspense fallback={<Loading></Loading>}>
                 <Home />
@@ -68,7 +68,7 @@ function App() {
             }
           />
           <Route
-            path="/channel"
+            path='/channel'
             element={
               <PrivateRoute>
                 <Channel />
@@ -76,7 +76,7 @@ function App() {
             }
           />
           <Route
-            path="/channel/edit/:id"
+            path='/channel/edit/:id'
             element={
               <Suspense fallback={<Loading></Loading>}>
                 <EditChannel />
@@ -84,7 +84,7 @@ function App() {
             }
           />
           <Route
-            path="/channel/add"
+            path='/channel/add'
             element={
               <Suspense fallback={<Loading></Loading>}>
                 <EditChannel />
@@ -92,7 +92,7 @@ function App() {
             }
           />
           <Route
-            path="/token"
+            path='/token'
             element={
               <PrivateRoute>
                 <Token />
@@ -100,7 +100,7 @@ function App() {
             }
           />
           <Route
-            path="/redemption"
+            path='/redemption'
             element={
               <PrivateRoute>
                 <Redemption />
@@ -108,7 +108,7 @@ function App() {
             }
           />
           <Route
-            path="/user"
+            path='/user'
             element={
               <PrivateRoute>
                 <User />
@@ -116,7 +116,7 @@ function App() {
             }
           />
           <Route
-            path="/user/edit/:id"
+            path='/user/edit/:id'
             element={
               <Suspense fallback={<Loading></Loading>}>
                 <EditUser />
@@ -124,7 +124,7 @@ function App() {
             }
           />
           <Route
-            path="/user/edit"
+            path='/user/edit'
             element={
               <Suspense fallback={<Loading></Loading>}>
                 <EditUser />
@@ -132,7 +132,7 @@ function App() {
             }
           />
           <Route
-            path="/user/reset"
+            path='/user/reset'
             element={
               <Suspense fallback={<Loading></Loading>}>
                 <PasswordResetConfirm />
@@ -140,7 +140,7 @@ function App() {
             }
           />
           <Route
-            path="/login"
+            path='/login'
             element={
               <Suspense fallback={<Loading></Loading>}>
                 <LoginForm />
@@ -148,7 +148,7 @@ function App() {
             }
           />
           <Route
-            path="/register"
+            path='/register'
             element={
               <Suspense fallback={<Loading></Loading>}>
                 <RegisterForm />
@@ -156,7 +156,7 @@ function App() {
             }
           />
           <Route
-            path="/reset"
+            path='/reset'
             element={
               <Suspense fallback={<Loading></Loading>}>
                 <PasswordResetForm />
@@ -164,7 +164,7 @@ function App() {
             }
           />
           <Route
-            path="/oauth/github"
+            path='/oauth/github'
             element={
               <Suspense fallback={<Loading></Loading>}>
                 <GitHubOAuth />
@@ -172,7 +172,7 @@ function App() {
             }
           />
           <Route
-            path="/setting"
+            path='/setting'
             element={
               <PrivateRoute>
                 <Suspense fallback={<Loading></Loading>}>
@@ -182,7 +182,7 @@ function App() {
             }
           />
           <Route
-            path="/topup"
+            path='/topup'
             element={
               <PrivateRoute>
                 <Suspense fallback={<Loading></Loading>}>
@@ -192,7 +192,7 @@ function App() {
             }
           />
           <Route
-            path="/log"
+            path='/log'
             element={
               <PrivateRoute>
                 <Log />
@@ -200,7 +200,7 @@ function App() {
             }
           />
           <Route
-            path="/detail"
+            path='/detail'
             element={
               <PrivateRoute>
                 <Suspense fallback={<Loading></Loading>}>
@@ -210,7 +210,7 @@ function App() {
             }
           />
           <Route
-            path="/midjourney"
+            path='/midjourney'
             element={
               <PrivateRoute>
                 <Suspense fallback={<Loading></Loading>}>
@@ -220,7 +220,7 @@ function App() {
             }
           />
           <Route
-            path="/about"
+            path='/about'
             element={
               <Suspense fallback={<Loading></Loading>}>
                 <About />
@@ -228,16 +228,14 @@ function App() {
             }
           />
           <Route
-            path="/chat"
+            path='/chat'
             element={
               <Suspense fallback={<Loading></Loading>}>
                 <Chat />
               </Suspense>
             }
           />
-          <Route path="*" element={
-            <NotFound />
-          } />
+          <Route path='*' element={<NotFound />} />
         </Routes>
       </Layout.Content>
     </Layout>

+ 336 - 203
web/src/components/ChannelsTable.js

@@ -1,31 +1,39 @@
 import React, { useEffect, useState } from 'react';
-import { API, isMobile, shouldShowPrompt, showError, showInfo, showSuccess, timestamp2string } from '../helpers';
+import {
+  API,
+  isMobile,
+  shouldShowPrompt,
+  showError,
+  showInfo,
+  showSuccess,
+  timestamp2string,
+} from '../helpers';
 
 import { CHANNEL_OPTIONS, ITEMS_PER_PAGE } from '../constants';
-import { renderGroup, renderNumberWithPoint, renderQuota } from '../helpers/render';
 import {
-    Button,
-    Dropdown,
-    Form,
-    InputNumber,
-    Popconfirm,
-    Space,
-    SplitButtonGroup,
-    Switch,
-    Table,
-    Tag,
-    Tooltip,
-    Typography
+  renderGroup,
+  renderNumberWithPoint,
+  renderQuota,
+} from '../helpers/render';
+import {
+  Button,
+  Dropdown,
+  Form,
+  InputNumber,
+  Popconfirm,
+  Space,
+  SplitButtonGroup,
+  Switch,
+  Table,
+  Tag,
+  Tooltip,
+  Typography,
 } from '@douyinfe/semi-ui';
 import EditChannel from '../pages/Channel/EditChannel';
 import { IconTreeTriangleDown } from '@douyinfe/semi-icons';
 
 function renderTimestamp(timestamp) {
-  return (
-    <>
-      {timestamp2string(timestamp)}
-    </>
-  );
+  return <>{timestamp2string(timestamp)}</>;
 }
 
 let type2label = undefined;
@@ -38,7 +46,11 @@ function renderType(type) {
     }
     type2label[0] = { value: 0, text: '未知类型', color: 'grey' };
   }
-  return <Tag size="large" color={type2label[type]?.color}>{type2label[type]?.text}</Tag>;
+  return (
+    <Tag size='large' color={type2label[type]?.color}>
+      {type2label[type]?.text}
+    </Tag>
+  );
 }
 
 const ChannelsTable = () => {
@@ -50,11 +62,11 @@ const ChannelsTable = () => {
     // },
     {
       title: 'ID',
-      dataIndex: 'id'
+      dataIndex: 'id',
     },
     {
       title: '名称',
-      dataIndex: 'name'
+      dataIndex: 'name',
     },
     {
       title: '分组',
@@ -63,48 +75,34 @@ const ChannelsTable = () => {
         return (
           <div>
             <Space spacing={2}>
-              {
-                text.split(',').map((item, index) => {
-                  return (renderGroup(item));
-                })
-              }
+              {text.split(',').map((item, index) => {
+                return renderGroup(item);
+              })}
             </Space>
           </div>
         );
-      }
+      },
     },
     {
       title: '类型',
       dataIndex: 'type',
       render: (text, record, index) => {
-        return (
-          <div>
-            {renderType(text)}
-          </div>
-        );
-      }
+        return <div>{renderType(text)}</div>;
+      },
     },
     {
       title: '状态',
       dataIndex: 'status',
       render: (text, record, index) => {
-        return (
-          <div>
-            {renderStatus(text)}
-          </div>
-        );
-      }
+        return <div>{renderStatus(text)}</div>;
+      },
     },
     {
       title: '响应时间',
       dataIndex: 'response_time',
       render: (text, record, index) => {
-        return (
-          <div>
-            {renderResponseTime(text)}
-          </div>
-        );
-      }
+        return <div>{renderResponseTime(text)}</div>;
+      },
     },
     {
       title: '已用/剩余',
@@ -114,17 +112,26 @@ const ChannelsTable = () => {
           <div>
             <Space spacing={1}>
               <Tooltip content={'已用额度'}>
-                <Tag color="white" type="ghost" size="large">{renderQuota(record.used_quota)}</Tag>
+                <Tag color='white' type='ghost' size='large'>
+                  {renderQuota(record.used_quota)}
+                </Tag>
               </Tooltip>
               <Tooltip content={'剩余额度' + record.balance + ',点击更新'}>
-                <Tag color="white" type="ghost" size="large" onClick={() => {
-                  updateChannelBalance(record);
-                }}>${renderNumberWithPoint(record.balance)}</Tag>
+                <Tag
+                  color='white'
+                  type='ghost'
+                  size='large'
+                  onClick={() => {
+                    updateChannelBalance(record);
+                  }}
+                >
+                  ${renderNumberWithPoint(record.balance)}
+                </Tag>
               </Tooltip>
             </Space>
           </div>
         );
-      }
+      },
     },
     {
       title: '优先级',
@@ -134,8 +141,8 @@ const ChannelsTable = () => {
           <div>
             <InputNumber
               style={{ width: 70 }}
-              name="priority"
-              onBlur={e => {
+              name='priority'
+              onBlur={(e) => {
                 manageChannel(record.id, 'priority', record, e.target.value);
               }}
               keepFocus={true}
@@ -145,7 +152,7 @@ const ChannelsTable = () => {
             />
           </div>
         );
-      }
+      },
     },
     {
       title: '权重',
@@ -155,8 +162,8 @@ const ChannelsTable = () => {
           <div>
             <InputNumber
               style={{ width: 70 }}
-              name="weight"
-              onBlur={e => {
+              name='weight'
+              onBlur={(e) => {
                 manageChannel(record.id, 'weight', record, e.target.value);
               }}
               keepFocus={true}
@@ -166,68 +173,90 @@ const ChannelsTable = () => {
             />
           </div>
         );
-      }
+      },
     },
     {
       title: '',
       dataIndex: 'operate',
       render: (text, record, index) => (
         <div>
-          <SplitButtonGroup style={{ marginRight: 1 }} aria-label="测试操作项目组">
-            <Button theme="light" onClick={() => {
-              testChannel(record, '');
-            }}>测试</Button>
-            <Dropdown trigger="click" position="bottomRight" menu={record.test_models}
+          <SplitButtonGroup
+            style={{ marginRight: 1 }}
+            aria-label='测试操作项目组'
+          >
+            <Button
+              theme='light'
+              onClick={() => {
+                testChannel(record, '');
+              }}
             >
-              <Button style={{ padding: '8px 4px' }} type="primary" icon={<IconTreeTriangleDown />}></Button>
+              测试
+            </Button>
+            <Dropdown
+              trigger='click'
+              position='bottomRight'
+              menu={record.test_models}
+            >
+              <Button
+                style={{ padding: '8px 4px' }}
+                type='primary'
+                icon={<IconTreeTriangleDown />}
+              ></Button>
             </Dropdown>
           </SplitButtonGroup>
           {/*<Button theme='light' type='primary' style={{marginRight: 1}} onClick={()=>testChannel(record)}>测试</Button>*/}
           <Popconfirm
-            title="确定是否要删除此渠道?"
-            content="此修改将不可逆"
+            title='确定是否要删除此渠道?'
+            content='此修改将不可逆'
             okType={'danger'}
             position={'left'}
             onConfirm={() => {
-              manageChannel(record.id, 'delete', record).then(
-                () => {
-                  removeRecord(record.id);
-                }
-              );
+              manageChannel(record.id, 'delete', record).then(() => {
+                removeRecord(record.id);
+              });
             }}
           >
-            <Button theme="light" type="danger" style={{ marginRight: 1 }}>删除</Button>
+            <Button theme='light' type='danger' style={{ marginRight: 1 }}>
+              删除
+            </Button>
           </Popconfirm>
-          {
-            record.status === 1 ?
-              <Button theme="light" type="warning" style={{ marginRight: 1 }} onClick={
-                async () => {
-                  manageChannel(
-                    record.id,
-                    'disable',
-                    record
-                  );
-                }
-              }>禁用</Button> :
-              <Button theme="light" type="secondary" style={{ marginRight: 1 }} onClick={
-                async () => {
-                  manageChannel(
-                    record.id,
-                    'enable',
-                    record
-                  );
-                }
-              }>启用</Button>
-          }
-          <Button theme="light" type="tertiary" style={{ marginRight: 1 }} onClick={
-            () => {
+          {record.status === 1 ? (
+            <Button
+              theme='light'
+              type='warning'
+              style={{ marginRight: 1 }}
+              onClick={async () => {
+                manageChannel(record.id, 'disable', record);
+              }}
+            >
+              禁用
+            </Button>
+          ) : (
+            <Button
+              theme='light'
+              type='secondary'
+              style={{ marginRight: 1 }}
+              onClick={async () => {
+                manageChannel(record.id, 'enable', record);
+              }}
+            >
+              启用
+            </Button>
+          )}
+          <Button
+            theme='light'
+            type='tertiary'
+            style={{ marginRight: 1 }}
+            onClick={() => {
               setEditingChannel(record);
               setShowEdit(true);
-            }
-          }>编辑</Button>
+            }}
+          >
+            编辑
+          </Button>
         </div>
-      )
-    }
+      ),
+    },
   ];
 
   const [channels, setChannels] = useState([]);
@@ -240,20 +269,22 @@ const ChannelsTable = () => {
   const [searching, setSearching] = useState(false);
   const [updatingBalance, setUpdatingBalance] = useState(false);
   const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
-  const [showPrompt, setShowPrompt] = useState(shouldShowPrompt('channel-test'));
+  const [showPrompt, setShowPrompt] = useState(
+    shouldShowPrompt('channel-test'),
+  );
   const [channelCount, setChannelCount] = useState(pageSize);
   const [groupOptions, setGroupOptions] = useState([]);
   const [showEdit, setShowEdit] = useState(false);
   const [enableBatchDelete, setEnableBatchDelete] = useState(false);
   const [editingChannel, setEditingChannel] = useState({
-    id: undefined
+    id: undefined,
   });
   const [selectedChannels, setSelectedChannels] = useState([]);
 
-  const removeRecord = id => {
+  const removeRecord = (id) => {
     let newDataSource = [...channels];
     if (id != null) {
-      let idx = newDataSource.findIndex(data => data.id === id);
+      let idx = newDataSource.findIndex((data) => data.id === id);
 
       if (idx > -1) {
         newDataSource.splice(idx, 1);
@@ -272,7 +303,7 @@ const ChannelsTable = () => {
           name: item,
           onClick: () => {
             testChannel(channels[i], item);
-          }
+          },
         });
       });
       channels[i].test_models = test_models;
@@ -288,7 +319,9 @@ const ChannelsTable = () => {
 
   const loadChannels = async (startIdx, pageSize, idSort) => {
     setLoading(true);
-    const res = await API.get(`/api/channel/?p=${startIdx}&page_size=${pageSize}&id_sort=${idSort}`);
+    const res = await API.get(
+      `/api/channel/?p=${startIdx}&page_size=${pageSize}&id_sort=${idSort}`,
+    );
     const { success, message, data } = res.data;
     if (success) {
       if (startIdx === 0) {
@@ -311,7 +344,8 @@ const ChannelsTable = () => {
   useEffect(() => {
     // console.log('default effect')
     const localIdSort = localStorage.getItem('id-sort') === 'true';
-    const localPageSize = parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
+    const localPageSize =
+      parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
     setIdSort(localIdSort);
     setPageSize(localPageSize);
     loadChannels(0, localPageSize, localIdSort)
@@ -361,7 +395,6 @@ const ChannelsTable = () => {
       let channel = res.data.data;
       let newChannels = [...channels];
       if (action === 'delete') {
-
       } else {
         record.status = channel.status;
       }
@@ -374,22 +407,26 @@ const ChannelsTable = () => {
   const renderStatus = (status) => {
     switch (status) {
       case 1:
-        return <Tag size="large" color="green">已启用</Tag>;
+        return (
+          <Tag size='large' color='green'>
+            已启用
+          </Tag>
+        );
       case 2:
         return (
-          <Tag size="large" color="yellow">
+          <Tag size='large' color='yellow'>
             已禁用
           </Tag>
         );
       case 3:
         return (
-          <Tag size="large" color="yellow">
+          <Tag size='large' color='yellow'>
             自动禁用
           </Tag>
         );
       default:
         return (
-          <Tag size="large" color="grey">
+          <Tag size='large' color='grey'>
             未知状态
           </Tag>
         );
@@ -400,15 +437,35 @@ const ChannelsTable = () => {
     let time = responseTime / 1000;
     time = time.toFixed(2) + ' 秒';
     if (responseTime === 0) {
-      return <Tag size="large" color="grey">未测试</Tag>;
+      return (
+        <Tag size='large' color='grey'>
+          未测试
+        </Tag>
+      );
     } else if (responseTime <= 1000) {
-      return <Tag size="large" color="green">{time}</Tag>;
+      return (
+        <Tag size='large' color='green'>
+          {time}
+        </Tag>
+      );
     } else if (responseTime <= 3000) {
-      return <Tag size="large" color="lime">{time}</Tag>;
+      return (
+        <Tag size='large' color='lime'>
+          {time}
+        </Tag>
+      );
     } else if (responseTime <= 5000) {
-      return <Tag size="large" color="yellow">{time}</Tag>;
+      return (
+        <Tag size='large' color='yellow'>
+          {time}
+        </Tag>
+      );
     } else {
-      return <Tag size="large" color="red">{time}</Tag>;
+      return (
+        <Tag size='large' color='red'>
+          {time}
+        </Tag>
+      );
     }
   };
 
@@ -420,7 +477,9 @@ const ChannelsTable = () => {
       return;
     }
     setSearching(true);
-    const res = await API.get(`/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}`);
+    const res = await API.get(
+      `/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}`,
+    );
     const { success, message, data } = res.data;
     if (success) {
       setChannels(data);
@@ -520,14 +579,16 @@ const ChannelsTable = () => {
     }
   };
 
-  let pageData = channels.slice((activePage - 1) * pageSize, activePage * pageSize);
+  let pageData = channels.slice(
+    (activePage - 1) * pageSize,
+    activePage * pageSize,
+  );
 
-  const handlePageChange = page => {
+  const handlePageChange = (page) => {
     setActivePage(page);
     if (page === Math.ceil(channels.length / pageSize) + 1) {
       // In this case we have to load more data and then append them.
-      loadChannels(page - 1, pageSize, idSort).then(r => {
-      });
+      loadChannels(page - 1, pageSize, idSort).then((r) => {});
     }
   };
 
@@ -547,10 +608,12 @@ const ChannelsTable = () => {
       let res = await API.get(`/api/group/`);
       // add 'all' option
       // res.data.data.unshift('all');
-      setGroupOptions(res.data.data.map((group) => ({
-        label: group,
-        value: group
-      })));
+      setGroupOptions(
+        res.data.data.map((group) => ({
+          label: group,
+          value: group,
+        })),
+      );
     } catch (error) {
       showError(error.message);
     }
@@ -564,27 +627,34 @@ const ChannelsTable = () => {
     if (record.status !== 1) {
       return {
         style: {
-          background: 'var(--semi-color-disabled-border)'
-        }
+          background: 'var(--semi-color-disabled-border)',
+        },
       };
     } else {
       return {};
     }
   };
 
-
   return (
     <>
-      <EditChannel refresh={refresh} visible={showEdit} handleClose={closeEdit} editingChannel={editingChannel} />
-      <Form onSubmit={() => {
-        searchChannels(searchKeyword, searchGroup, searchModel);
-      }} labelPosition="left">
+      <EditChannel
+        refresh={refresh}
+        visible={showEdit}
+        handleClose={closeEdit}
+        editingChannel={editingChannel}
+      />
+      <Form
+        onSubmit={() => {
+          searchChannels(searchKeyword, searchGroup, searchModel);
+        }}
+        labelPosition='left'
+      >
         <div style={{ display: 'flex' }}>
           <Space>
             <Form.Input
-              field="search_keyword"
-              label="搜索渠道关键词"
-              placeholder="ID,名称和密钥 ..."
+              field='search_keyword'
+              label='搜索渠道关键词'
+              placeholder='ID,名称和密钥 ...'
               value={searchKeyword}
               loading={searching}
               onChange={(v) => {
@@ -592,21 +662,33 @@ const ChannelsTable = () => {
               }}
             />
             <Form.Input
-              field="search_model"
-              label="模型"
-              placeholder="模型关键字"
+              field='search_model'
+              label='模型'
+              placeholder='模型关键字'
               value={searchModel}
               loading={searching}
               onChange={(v) => {
                 setSearchModel(v.trim());
               }}
             />
-            <Form.Select field="group" label="分组" optionList={groupOptions} onChange={(v) => {
-              setSearchGroup(v);
-              searchChannels(searchKeyword, v, searchModel);
-            }} />
-            <Button label="查询" type="primary" htmlType="submit" className="btn-margin-right"
-                    style={{ marginRight: 8 }}>查询</Button>
+            <Form.Select
+              field='group'
+              label='分组'
+              optionList={groupOptions}
+              onChange={(v) => {
+                setSearchGroup(v);
+                searchChannels(searchKeyword, v, searchModel);
+              }}
+            />
+            <Button
+              label='查询'
+              type='primary'
+              htmlType='submit'
+              className='btn-margin-right'
+              style={{ marginRight: 8 }}
+            >
+              查询
+            </Button>
           </Space>
         </div>
       </Form>
@@ -614,80 +696,118 @@ const ChannelsTable = () => {
         <Space>
           <Space>
             <Typography.Text strong>使用ID排序</Typography.Text>
-            <Switch checked={idSort} label="使用ID排序" uncheckedText="关" aria-label="是否用ID排序" onChange={(v) => {
-              localStorage.setItem('id-sort', v + '');
-              setIdSort(v);
-              loadChannels(0, pageSize, v)
-                .then()
-                .catch((reason) => {
-                  showError(reason);
-                });
-            }}></Switch>
+            <Switch
+              checked={idSort}
+              label='使用ID排序'
+              uncheckedText='关'
+              aria-label='是否用ID排序'
+              onChange={(v) => {
+                localStorage.setItem('id-sort', v + '');
+                setIdSort(v);
+                loadChannels(0, pageSize, v)
+                  .then()
+                  .catch((reason) => {
+                    showError(reason);
+                  });
+              }}
+            ></Switch>
           </Space>
         </Space>
       </div>
 
-      <Table className={'channel-table'} style={{ marginTop: 15 }} columns={columns} dataSource={pageData} pagination={{
-        currentPage: activePage,
-        pageSize: pageSize,
-        total: channelCount,
-        pageSizeOpts: [10, 20, 50, 100],
-        showSizeChanger: true,
-        formatPageText: (page) => '',
-        onPageSizeChange: (size) => {
-          handlePageSizeChange(size).then();
-        },
-        onPageChange: handlePageChange
-      }} loading={loading} onRow={handleRow} rowSelection={
-        enableBatchDelete ?
-          {
-            onChange: (selectedRowKeys, selectedRows) => {
-              // console.log(`selectedRowKeys: ${selectedRowKeys}`, 'selectedRows: ', selectedRows);
-              setSelectedChannels(selectedRows);
-            }
-          } : null
-      } />
-      <div style={{
-        display: isMobile() ? '' : 'flex',
-        marginTop: isMobile() ? 0 : -45,
-        zIndex: 999,
-        position: 'relative',
-        pointerEvents: 'none'
-      }}>
-        <Space style={{ pointerEvents: 'auto', marginTop: isMobile() ? 0 : 45 }}>
-          <Button theme="light" type="primary" style={{ marginRight: 8 }} onClick={
-            () => {
+      <Table
+        className={'channel-table'}
+        style={{ marginTop: 15 }}
+        columns={columns}
+        dataSource={pageData}
+        pagination={{
+          currentPage: activePage,
+          pageSize: pageSize,
+          total: channelCount,
+          pageSizeOpts: [10, 20, 50, 100],
+          showSizeChanger: true,
+          formatPageText: (page) => '',
+          onPageSizeChange: (size) => {
+            handlePageSizeChange(size).then();
+          },
+          onPageChange: handlePageChange,
+        }}
+        loading={loading}
+        onRow={handleRow}
+        rowSelection={
+          enableBatchDelete
+            ? {
+                onChange: (selectedRowKeys, selectedRows) => {
+                  // console.log(`selectedRowKeys: ${selectedRowKeys}`, 'selectedRows: ', selectedRows);
+                  setSelectedChannels(selectedRows);
+                },
+              }
+            : null
+        }
+      />
+      <div
+        style={{
+          display: isMobile() ? '' : 'flex',
+          marginTop: isMobile() ? 0 : -45,
+          zIndex: 999,
+          position: 'relative',
+          pointerEvents: 'none',
+        }}
+      >
+        <Space
+          style={{ pointerEvents: 'auto', marginTop: isMobile() ? 0 : 45 }}
+        >
+          <Button
+            theme='light'
+            type='primary'
+            style={{ marginRight: 8 }}
+            onClick={() => {
               setEditingChannel({
-                id: undefined
+                id: undefined,
               });
               setShowEdit(true);
-            }
-          }>添加渠道</Button>
+            }}
+          >
+            添加渠道
+          </Button>
           <Popconfirm
-            title="确定?"
+            title='确定?'
             okType={'warning'}
             onConfirm={testAllChannels}
             position={isMobile() ? 'top' : 'top'}
           >
-            <Button theme="light" type="warning" style={{ marginRight: 8 }}>测试所有通道</Button>
+            <Button theme='light' type='warning' style={{ marginRight: 8 }}>
+              测试所有通道
+            </Button>
           </Popconfirm>
           <Popconfirm
-            title="确定?"
+            title='确定?'
             okType={'secondary'}
             onConfirm={updateAllChannelsBalance}
           >
-            <Button theme="light" type="secondary" style={{ marginRight: 8 }}>更新所有已启用通道余额</Button>
+            <Button theme='light' type='secondary' style={{ marginRight: 8 }}>
+              更新所有已启用通道余额
+            </Button>
           </Popconfirm>
           <Popconfirm
-            title="确定是否要删除禁用通道?"
-            content="此修改将不可逆"
+            title='确定是否要删除禁用通道?'
+            content='此修改将不可逆'
             okType={'danger'}
             onConfirm={deleteAllDisabledChannels}
           >
-            <Button theme="light" type="danger" style={{ marginRight: 8 }}>删除禁用通道</Button>
+            <Button theme='light' type='danger' style={{ marginRight: 8 }}>
+              删除禁用通道
+            </Button>
           </Popconfirm>
 
-          <Button theme="light" type="primary" style={{ marginRight: 8 }} onClick={refresh}>刷新</Button>
+          <Button
+            theme='light'
+            type='primary'
+            style={{ marginRight: 8 }}
+            onClick={refresh}
+          >
+            刷新
+          </Button>
         </Space>
         {/*<div style={{width: '100%', pointerEvents: 'none', position: 'absolute'}}>*/}
 
@@ -696,28 +816,41 @@ const ChannelsTable = () => {
       <div style={{ marginTop: 20 }}>
         <Space>
           <Typography.Text strong>开启批量删除</Typography.Text>
-          <Switch label="开启批量删除" uncheckedText="关" aria-label="是否开启批量删除" onChange={(v) => {
-            setEnableBatchDelete(v);
-          }}></Switch>
+          <Switch
+            label='开启批量删除'
+            uncheckedText='关'
+            aria-label='是否开启批量删除'
+            onChange={(v) => {
+              setEnableBatchDelete(v);
+            }}
+          ></Switch>
           <Popconfirm
-            title="确定是否要删除所选通道?"
-            content="此修改将不可逆"
+            title='确定是否要删除所选通道?'
+            content='此修改将不可逆'
             okType={'danger'}
             onConfirm={batchDeleteChannels}
             disabled={!enableBatchDelete}
             position={'top'}
           >
-            <Button disabled={!enableBatchDelete} theme="light" type="danger"
-                    style={{ marginRight: 8 }}>删除所选通道</Button>
+            <Button
+              disabled={!enableBatchDelete}
+              theme='light'
+              type='danger'
+              style={{ marginRight: 8 }}
+            >
+              删除所选通道
+            </Button>
           </Popconfirm>
           <Popconfirm
-            title="确定是否要修复数据库一致性?"
-            content="进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用"
+            title='确定是否要修复数据库一致性?'
+            content='进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用'
             okType={'warning'}
             onConfirm={fixChannelsAbilities}
             position={'top'}
           >
-            <Button theme="light" type="secondary" style={{ marginRight: 8 }}>修复数据库一致性</Button>
+            <Button theme='light' type='secondary' style={{ marginRight: 8 }}>
+              修复数据库一致性
+            </Button>
           </Popconfirm>
         </Space>
       </div>

+ 16 - 7
web/src/components/Footer.js

@@ -32,27 +32,36 @@ const Footer = () => {
       <Layout.Content style={{ textAlign: 'center' }}>
         {footer ? (
           <div
-            className="custom-footer"
+            className='custom-footer'
             dangerouslySetInnerHTML={{ __html: footer }}
           ></div>
         ) : (
-          <div className="custom-footer">
+          <div className='custom-footer'>
             <a
-              href="https://github.com/Calcium-Ion/new-api"
-              target="_blank" rel="noreferrer"
+              href='https://github.com/Calcium-Ion/new-api'
+              target='_blank'
+              rel='noreferrer'
             >
               New API {import.meta.env.VITE_REACT_APP_VERSION}{' '}
             </a>
             由{' '}
-            <a href="https://github.com/Calcium-Ion" target="_blank" rel="noreferrer">
+            <a
+              href='https://github.com/Calcium-Ion'
+              target='_blank'
+              rel='noreferrer'
+            >
               Calcium-Ion
             </a>{' '}
             开发,基于{' '}
-            <a href="https://github.com/songquanpeng/one-api" target="_blank" rel="noreferrer">
+            <a
+              href='https://github.com/songquanpeng/one-api'
+              target='_blank'
+              rel='noreferrer'
+            >
               One API v0.5.4
             </a>{' '}
             ,本项目根据{' '}
-            <a href="https://opensource.org/licenses/mit-license.php">
+            <a href='https://opensource.org/licenses/mit-license.php'>
               MIT 许可证
             </a>{' '}
             授权

+ 1 - 1
web/src/components/GitHubOAuth.js

@@ -49,7 +49,7 @@ const GitHubOAuth = () => {
   return (
     <Segment style={{ minHeight: '300px' }}>
       <Dimmer active inverted>
-        <Loader size="large">{prompt}</Loader>
+        <Loader size='large'>{prompt}</Loader>
       </Dimmer>
     </Segment>
   );

+ 43 - 22
web/src/components/HeaderBar.js

@@ -17,15 +17,15 @@ let headerButtons = [
     text: '关于',
     itemKey: 'about',
     to: '/about',
-    icon: <IconHelpCircle />
-  }
+    icon: <IconHelpCircle />,
+  },
 ];
 
 if (localStorage.getItem('chat_link')) {
   headerButtons.splice(1, 0, {
     name: '聊天',
     to: '/chat',
-    icon: 'comments'
+    icon: 'comments',
   });
 }
 
@@ -40,7 +40,11 @@ const HeaderBar = () => {
   var themeMode = localStorage.getItem('theme-mode');
   const currentDate = new Date();
   // enable fireworks on new year(1.1 and 2.9-2.24)
-  const isNewYear = (currentDate.getMonth() === 0 && currentDate.getDate() === 1) || (currentDate.getMonth() === 1 && currentDate.getDate() >= 9 && currentDate.getDate() <= 24);
+  const isNewYear =
+    (currentDate.getMonth() === 0 && currentDate.getDate() === 1) ||
+    (currentDate.getMonth() === 1 &&
+      currentDate.getDate() >= 9 &&
+      currentDate.getDate() <= 24);
 
   async function logout() {
     setShowSidebar(false);
@@ -93,7 +97,7 @@ const HeaderBar = () => {
               const routerMap = {
                 about: '/about',
                 login: '/login',
-                register: '/register'
+                register: '/register',
               };
               return (
                 <Link
@@ -106,52 +110,69 @@ const HeaderBar = () => {
             }}
             selectedKeys={[]}
             // items={headerButtons}
-            onSelect={key => {
-
-            }}
+            onSelect={(key) => {}}
             footer={
               <>
-                {isNewYear &&
+                {isNewYear && (
                   // happy new year
                   <Dropdown
-                    position="bottomRight"
+                    position='bottomRight'
                     render={
                       <Dropdown.Menu>
-                        <Dropdown.Item onClick={handleNewYearClick}>Happy New Year!!!</Dropdown.Item>
+                        <Dropdown.Item onClick={handleNewYearClick}>
+                          Happy New Year!!!
+                        </Dropdown.Item>
                       </Dropdown.Menu>
                     }
                   >
                     <Nav.Item itemKey={'new-year'} text={'🏮'} />
                   </Dropdown>
-                }
+                )}
                 <Nav.Item itemKey={'about'} icon={<IconHelpCircle />} />
-                <Switch checkedText="🌞" size={'large'} checked={dark} uncheckedText="🌙" onChange={switchMode} />
-                {userState.user ?
+                <Switch
+                  checkedText='🌞'
+                  size={'large'}
+                  checked={dark}
+                  uncheckedText='🌙'
+                  onChange={switchMode}
+                />
+                {userState.user ? (
                   <>
                     <Dropdown
-                      position="bottomRight"
+                      position='bottomRight'
                       render={
                         <Dropdown.Menu>
                           <Dropdown.Item onClick={logout}>退出</Dropdown.Item>
                         </Dropdown.Menu>
                       }
                     >
-                      <Avatar size="small" color={stringToColor(userState.user.username)} style={{ margin: 4 }}>
+                      <Avatar
+                        size='small'
+                        color={stringToColor(userState.user.username)}
+                        style={{ margin: 4 }}
+                      >
                         {userState.user.username[0]}
                       </Avatar>
                       <span>{userState.user.username}</span>
                     </Dropdown>
                   </>
-                  :
+                ) : (
                   <>
-                    <Nav.Item itemKey={'login'} text={'登录'} icon={<IconKey />} />
-                    <Nav.Item itemKey={'register'} text={'注册'} icon={<IconUser />} />
+                    <Nav.Item
+                      itemKey={'login'}
+                      text={'登录'}
+                      icon={<IconKey />}
+                    />
+                    <Nav.Item
+                      itemKey={'register'}
+                      text={'注册'}
+                      icon={<IconUser />}
+                    />
                   </>
-                }
+                )}
               </>
             }
-          >
-          </Nav>
+          ></Nav>
         </div>
       </Layout>
     </>

+ 3 - 1
web/src/components/Loading.js

@@ -3,7 +3,9 @@ import { Spin } from '@douyinfe/semi-ui';
 
 const Loading = ({ prompt: name = 'page' }) => {
   return (
-    <Spin style={{height: 100}} spinning={true}>加载{name}中...</Spin>
+    <Spin style={{ height: 100 }} spinning={true}>
+      加载{name}中...
+    </Spin>
   );
 };
 

+ 103 - 36
web/src/components/LoginForm.js

@@ -4,7 +4,15 @@ import { UserContext } from '../context/User';
 import { API, getLogo, showError, showInfo, showSuccess } from '../helpers';
 import { onGitHubOAuthClicked } from './utils';
 import Turnstile from 'react-turnstile';
-import { Button, Card, Divider, Form, Icon, Layout, Modal } from '@douyinfe/semi-ui';
+import {
+  Button,
+  Card,
+  Divider,
+  Form,
+  Icon,
+  Layout,
+  Modal,
+} from '@douyinfe/semi-ui';
 import Title from '@douyinfe/semi-ui/lib/es/typography/title';
 import Text from '@douyinfe/semi-ui/lib/es/typography/text';
 import TelegramLoginButton from 'react-telegram-login';
@@ -16,7 +24,7 @@ const LoginForm = () => {
   const [inputs, setInputs] = useState({
     username: '',
     password: '',
-    wechat_verification_code: ''
+    wechat_verification_code: '',
   });
   const [searchParams, setSearchParams] = useSearchParams();
   const [submitted, setSubmitted] = useState(false);
@@ -56,7 +64,7 @@ const LoginForm = () => {
       return;
     }
     const res = await API.get(
-      `/api/oauth/wechat?code=${inputs.wechat_verification_code}`
+      `/api/oauth/wechat?code=${inputs.wechat_verification_code}`,
     );
     const { success, message, data } = res.data;
     if (success) {
@@ -81,17 +89,24 @@ const LoginForm = () => {
     }
     setSubmitted(true);
     if (username && password) {
-      const res = await API.post(`/api/user/login?turnstile=${turnstileToken}`, {
-        username,
-        password
-      });
+      const res = await API.post(
+        `/api/user/login?turnstile=${turnstileToken}`,
+        {
+          username,
+          password,
+        },
+      );
       const { success, message, data } = res.data;
       if (success) {
         userDispatch({ type: 'login', payload: data });
         localStorage.setItem('user', JSON.stringify(data));
         showSuccess('登录成功!');
         if (username === 'root' && password === '123456') {
-          Modal.error({ title: '您正在使用默认密码!', content: '请立刻修改默认密码!', centered: true });
+          Modal.error({
+            title: '您正在使用默认密码!',
+            content: '请立刻修改默认密码!',
+            centered: true,
+          });
         }
         navigate('/token');
       } else {
@@ -104,7 +119,16 @@ const LoginForm = () => {
 
   // 添加Telegram登录处理函数
   const onTelegramLoginClicked = async (response) => {
-    const fields = ['id', 'first_name', 'last_name', 'username', 'photo_url', 'auth_date', 'hash', 'lang'];
+    const fields = [
+      'id',
+      'first_name',
+      'last_name',
+      'username',
+      'photo_url',
+      'auth_date',
+      'hash',
+      'lang',
+    ];
     const params = {};
     fields.forEach((field) => {
       if (response[field]) {
@@ -126,10 +150,15 @@ const LoginForm = () => {
   return (
     <div>
       <Layout>
-        <Layout.Header>
-        </Layout.Header>
+        <Layout.Header></Layout.Header>
         <Layout.Content>
-          <div style={{ justifyContent: 'center', display: 'flex', marginTop: 120 }}>
+          <div
+            style={{
+              justifyContent: 'center',
+              display: 'flex',
+              marginTop: 120,
+            }}
+          >
             <div style={{ width: 500 }}>
               <Card>
                 <Title heading={2} style={{ textAlign: 'center' }}>
@@ -139,50 +168,72 @@ const LoginForm = () => {
                   <Form.Input
                     field={'username'}
                     label={'用户名'}
-                    placeholder="用户名"
-                    name="username"
+                    placeholder='用户名'
+                    name='username'
                     onChange={(value) => handleChange('username', value)}
                   />
                   <Form.Input
                     field={'password'}
                     label={'密码'}
-                    placeholder="密码"
-                    name="password"
-                    type="password"
+                    placeholder='密码'
+                    name='password'
+                    type='password'
                     onChange={(value) => handleChange('password', value)}
                   />
 
-                  <Button theme="solid" style={{ width: '100%' }} type={'primary'} size="large"
-                          htmlType={'submit'} onClick={handleSubmit}>
+                  <Button
+                    theme='solid'
+                    style={{ width: '100%' }}
+                    type={'primary'}
+                    size='large'
+                    htmlType={'submit'}
+                    onClick={handleSubmit}
+                  >
                     登录
                   </Button>
                 </Form>
-                <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 20 }}>
+                <div
+                  style={{
+                    display: 'flex',
+                    justifyContent: 'space-between',
+                    marginTop: 20,
+                  }}
+                >
                   <Text>
-                    没有账号请先 <Link to="/register">注册账号</Link>
+                    没有账号请先 <Link to='/register'>注册账号</Link>
                   </Text>
                   <Text>
-                    忘记密码 <Link to="/reset">点击重置</Link>
+                    忘记密码 <Link to='/reset'>点击重置</Link>
                   </Text>
                 </div>
-                {status.github_oauth || status.wechat_login || status.telegram_oauth ? (
+                {status.github_oauth ||
+                status.wechat_login ||
+                status.telegram_oauth ? (
                   <>
-                    <Divider margin="12px" align="center">
+                    <Divider margin='12px' align='center'>
                       第三方登录
                     </Divider>
-                    <div style={{ display: 'flex', justifyContent: 'center', marginTop: 20 }}>
+                    <div
+                      style={{
+                        display: 'flex',
+                        justifyContent: 'center',
+                        marginTop: 20,
+                      }}
+                    >
                       {status.github_oauth ? (
                         <Button
-                          type="primary"
+                          type='primary'
                           icon={<IconGithubLogo />}
-                          onClick={() => onGitHubOAuthClicked(status.github_client_id)}
+                          onClick={() =>
+                            onGitHubOAuthClicked(status.github_client_id)
+                          }
                         />
                       ) : (
                         <></>
                       )}
                       {status.wechat_login ? (
                         <Button
-                          type="primary"
+                          type='primary'
                           style={{ color: 'rgba(var(--semi-green-5), 1)' }}
                           icon={<Icon svg={<WeChatIcon />} />}
                           onClick={onWeChatLoginClicked}
@@ -192,7 +243,10 @@ const LoginForm = () => {
                       )}
 
                       {status.telegram_oauth ? (
-                        <TelegramLoginButton dataOnauth={onTelegramLoginClicked} botName={status.telegram_bot_name} />
+                        <TelegramLoginButton
+                          dataOnauth={onTelegramLoginClicked}
+                          botName={status.telegram_bot_name}
+                        />
                       ) : (
                         <></>
                       )}
@@ -202,7 +256,7 @@ const LoginForm = () => {
                   <></>
                 )}
                 <Modal
-                  title="微信扫码登录"
+                  title='微信扫码登录'
                   visible={showWeChatLoginModal}
                   maskClosable={true}
                   onOk={onSubmitWeChatVerificationCode}
@@ -211,7 +265,13 @@ const LoginForm = () => {
                   size={'small'}
                   centered={true}
                 >
-                  <div style={{ display: 'flex', alignItem: 'center', flexDirection: 'column' }}>
+                  <div
+                    style={{
+                      display: 'flex',
+                      alignItem: 'center',
+                      flexDirection: 'column',
+                    }}
+                  >
                     <img src={status.wechat_qrcode} />
                   </div>
                   <div style={{ textAlign: 'center' }}>
@@ -219,19 +279,27 @@ const LoginForm = () => {
                       微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)
                     </p>
                   </div>
-                  <Form size="large">
+                  <Form size='large'>
                     <Form.Input
                       field={'wechat_verification_code'}
-                      placeholder="验证码"
+                      placeholder='验证码'
                       label={'验证码'}
                       value={inputs.wechat_verification_code}
-                      onChange={(value) => handleChange('wechat_verification_code', value)}
+                      onChange={(value) =>
+                        handleChange('wechat_verification_code', value)
+                      }
                     />
                   </Form>
                 </Modal>
               </Card>
               {turnstileEnabled ? (
-                <div style={{ display: 'flex', justifyContent: 'center', marginTop: 20 }}>
+                <div
+                  style={{
+                    display: 'flex',
+                    justifyContent: 'center',
+                    marginTop: 20,
+                  }}
+                >
                   <Turnstile
                     sitekey={turnstileSiteKey}
                     onVerify={(token) => {
@@ -244,7 +312,6 @@ const LoginForm = () => {
               )}
             </div>
           </div>
-
         </Layout.Content>
       </Layout>
     </div>

+ 435 - 182
web/src/components/LogsTable.js

@@ -1,7 +1,25 @@
 import React, { useEffect, useState } from 'react';
-import { API, copy, isAdmin, showError, showSuccess, timestamp2string } from '../helpers';
+import {
+  API,
+  copy,
+  isAdmin,
+  showError,
+  showSuccess,
+  timestamp2string,
+} from '../helpers';
 
-import { Avatar, Button, Form, Layout, Modal, Select, Space, Spin, Table, Tag } from '@douyinfe/semi-ui';
+import {
+  Avatar,
+  Button,
+  Form,
+  Layout,
+  Modal,
+  Select,
+  Space,
+  Spin,
+  Table,
+  Tag,
+} from '@douyinfe/semi-ui';
 import { ITEMS_PER_PAGE } from '../constants';
 import { renderNumber, renderQuota, stringToColor } from '../helpers/render';
 import Paragraph from '@douyinfe/semi-ui/lib/es/typography/paragraph';
@@ -9,131 +27,285 @@ import Paragraph from '@douyinfe/semi-ui/lib/es/typography/paragraph';
 const { Header } = Layout;
 
 function renderTimestamp(timestamp) {
-  return (<>
-    {timestamp2string(timestamp)}
-  </>);
+  return <>{timestamp2string(timestamp)}</>;
 }
 
-const MODE_OPTIONS = [{ key: 'all', text: '全部用户', value: 'all' }, { key: 'self', text: '当前用户', value: 'self' }];
+const MODE_OPTIONS = [
+  { key: 'all', text: '全部用户', value: 'all' },
+  { key: 'self', text: '当前用户', value: 'self' },
+];
 
-const colors = ['amber', 'blue', 'cyan', 'green', 'grey', 'indigo', 'light-blue', 'lime', 'orange', 'pink', 'purple', 'red', 'teal', 'violet', 'yellow'];
+const colors = [
+  'amber',
+  'blue',
+  'cyan',
+  'green',
+  'grey',
+  'indigo',
+  'light-blue',
+  'lime',
+  'orange',
+  'pink',
+  'purple',
+  'red',
+  'teal',
+  'violet',
+  'yellow',
+];
 
 function renderType(type) {
   switch (type) {
     case 1:
-      return <Tag color="cyan" size="large"> 充值 </Tag>;
+      return (
+        <Tag color='cyan' size='large'>
+          {' '}
+          充值{' '}
+        </Tag>
+      );
     case 2:
-      return <Tag color="lime" size="large"> 消费 </Tag>;
+      return (
+        <Tag color='lime' size='large'>
+          {' '}
+          消费{' '}
+        </Tag>
+      );
     case 3:
-      return <Tag color="orange" size="large"> 管理 </Tag>;
+      return (
+        <Tag color='orange' size='large'>
+          {' '}
+          管理{' '}
+        </Tag>
+      );
     case 4:
-      return <Tag color="purple" size="large"> 系统 </Tag>;
+      return (
+        <Tag color='purple' size='large'>
+          {' '}
+          系统{' '}
+        </Tag>
+      );
     default:
-      return <Tag color="black" size="large"> 未知 </Tag>;
+      return (
+        <Tag color='black' size='large'>
+          {' '}
+          未知{' '}
+        </Tag>
+      );
   }
 }
 
 function renderIsStream(bool) {
   if (bool) {
-    return <Tag color="blue" size="large">流</Tag>;
+    return (
+      <Tag color='blue' size='large'>
+        流
+      </Tag>
+    );
   } else {
-    return <Tag color="purple" size="large">非流</Tag>;
+    return (
+      <Tag color='purple' size='large'>
+        非流
+      </Tag>
+    );
   }
 }
 
 function renderUseTime(type) {
   const time = parseInt(type);
   if (time < 101) {
-    return <Tag color="green" size="large"> {time} s </Tag>;
+    return (
+      <Tag color='green' size='large'>
+        {' '}
+        {time} s{' '}
+      </Tag>
+    );
   } else if (time < 300) {
-    return <Tag color="orange" size="large"> {time} s </Tag>;
+    return (
+      <Tag color='orange' size='large'>
+        {' '}
+        {time} s{' '}
+      </Tag>
+    );
   } else {
-    return <Tag color="red" size="large"> {time} s </Tag>;
+    return (
+      <Tag color='red' size='large'>
+        {' '}
+        {time} s{' '}
+      </Tag>
+    );
   }
 }
 
 const LogsTable = () => {
-  const columns = [{
-    title: '时间', dataIndex: 'timestamp2string'
-  }, {
-    title: '渠道',
-    dataIndex: 'channel',
-    className: isAdmin() ? 'tableShow' : 'tableHiddle',
-    render: (text, record, index) => {
-      return (isAdminUser ? record.type === 0 || record.type === 2 ? <div>
-        {<Tag color={colors[parseInt(text) % colors.length]} size="large"> {text} </Tag>}
-      </div> : <></> : <></>);
-    }
-  }, {
-    title: '用户',
-    dataIndex: 'username',
-    className: isAdmin() ? 'tableShow' : 'tableHiddle',
-    render: (text, record, index) => {
-      return (isAdminUser ? <div>
-        <Avatar size="small" color={stringToColor(text)} style={{ marginRight: 4 }}
-                onClick={() => showUserInfo(record.user_id)}>
-          {typeof text === 'string' && text.slice(0, 1)}
-        </Avatar>
-        {text}
-      </div> : <></>);
-    }
-  }, {
-    title: '令牌', dataIndex: 'token_name', render: (text, record, index) => {
-      return (record.type === 0 || record.type === 2 ? <div>
-        <Tag color="grey" size="large" onClick={() => {
-          copyText(text);
-        }}> {text} </Tag>
-      </div> : <></>);
-    }
-  }, {
-    title: '类型', dataIndex: 'type', render: (text, record, index) => {
-      return (<div>
-        {renderType(text)}
-      </div>);
-    }
-  }, {
-    title: '模型', dataIndex: 'model_name', render: (text, record, index) => {
-      return (record.type === 0 || record.type === 2 ? <div>
-        <Tag color={stringToColor(text)} size="large" onClick={() => {
-          copyText(text);
-        }}> {text} </Tag>
-      </div> : <></>);
-    }
-  }, {
-    title: '用时', dataIndex: 'use_time', render: (text, record, index) => {
-      return (<div>
-        <Space>
-          {renderUseTime(text)}
-          {renderIsStream(record.is_stream)}
-        </Space>
-      </div>);
-    }
-  }, {
-    title: '提示', dataIndex: 'prompt_tokens', render: (text, record, index) => {
-      return (record.type === 0 || record.type === 2 ? <div>
-        {<span> {text} </span>}
-      </div> : <></>);
-    }
-  }, {
-    title: '补全', dataIndex: 'completion_tokens', render: (text, record, index) => {
-      return (parseInt(text) > 0 && (record.type === 0 || record.type === 2) ? <div>
-        {<span> {text} </span>}
-      </div> : <></>);
-    }
-  }, {
-    title: '花费', dataIndex: 'quota', render: (text, record, index) => {
-      return (record.type === 0 || record.type === 2 ? <div>
-        {renderQuota(text, 6)}
-      </div> : <></>);
-    }
-  }, {
-    title: '详情', dataIndex: 'content', render: (text, record, index) => {
-      return <Paragraph ellipsis={{ rows: 2, showTooltip: { type: 'popover', opts: { style: { width: 240 } } } }}
-                        style={{ maxWidth: 240 }}>
-        {text}
-      </Paragraph>;
-    }
-  }];
+  const columns = [
+    {
+      title: '时间',
+      dataIndex: 'timestamp2string',
+    },
+    {
+      title: '渠道',
+      dataIndex: 'channel',
+      className: isAdmin() ? 'tableShow' : 'tableHiddle',
+      render: (text, record, index) => {
+        return isAdminUser ? (
+          record.type === 0 || record.type === 2 ? (
+            <div>
+              {
+                <Tag
+                  color={colors[parseInt(text) % colors.length]}
+                  size='large'
+                >
+                  {' '}
+                  {text}{' '}
+                </Tag>
+              }
+            </div>
+          ) : (
+            <></>
+          )
+        ) : (
+          <></>
+        );
+      },
+    },
+    {
+      title: '用户',
+      dataIndex: 'username',
+      className: isAdmin() ? 'tableShow' : 'tableHiddle',
+      render: (text, record, index) => {
+        return isAdminUser ? (
+          <div>
+            <Avatar
+              size='small'
+              color={stringToColor(text)}
+              style={{ marginRight: 4 }}
+              onClick={() => showUserInfo(record.user_id)}
+            >
+              {typeof text === 'string' && text.slice(0, 1)}
+            </Avatar>
+            {text}
+          </div>
+        ) : (
+          <></>
+        );
+      },
+    },
+    {
+      title: '令牌',
+      dataIndex: 'token_name',
+      render: (text, record, index) => {
+        return record.type === 0 || record.type === 2 ? (
+          <div>
+            <Tag
+              color='grey'
+              size='large'
+              onClick={() => {
+                copyText(text);
+              }}
+            >
+              {' '}
+              {text}{' '}
+            </Tag>
+          </div>
+        ) : (
+          <></>
+        );
+      },
+    },
+    {
+      title: '类型',
+      dataIndex: 'type',
+      render: (text, record, index) => {
+        return <div>{renderType(text)}</div>;
+      },
+    },
+    {
+      title: '模型',
+      dataIndex: 'model_name',
+      render: (text, record, index) => {
+        return record.type === 0 || record.type === 2 ? (
+          <div>
+            <Tag
+              color={stringToColor(text)}
+              size='large'
+              onClick={() => {
+                copyText(text);
+              }}
+            >
+              {' '}
+              {text}{' '}
+            </Tag>
+          </div>
+        ) : (
+          <></>
+        );
+      },
+    },
+    {
+      title: '用时',
+      dataIndex: 'use_time',
+      render: (text, record, index) => {
+        return (
+          <div>
+            <Space>
+              {renderUseTime(text)}
+              {renderIsStream(record.is_stream)}
+            </Space>
+          </div>
+        );
+      },
+    },
+    {
+      title: '提示',
+      dataIndex: 'prompt_tokens',
+      render: (text, record, index) => {
+        return record.type === 0 || record.type === 2 ? (
+          <div>{<span> {text} </span>}</div>
+        ) : (
+          <></>
+        );
+      },
+    },
+    {
+      title: '补全',
+      dataIndex: 'completion_tokens',
+      render: (text, record, index) => {
+        return parseInt(text) > 0 &&
+          (record.type === 0 || record.type === 2) ? (
+          <div>{<span> {text} </span>}</div>
+        ) : (
+          <></>
+        );
+      },
+    },
+    {
+      title: '花费',
+      dataIndex: 'quota',
+      render: (text, record, index) => {
+        return record.type === 0 || record.type === 2 ? (
+          <div>{renderQuota(text, 6)}</div>
+        ) : (
+          <></>
+        );
+      },
+    },
+    {
+      title: '详情',
+      dataIndex: 'content',
+      render: (text, record, index) => {
+        return (
+          <Paragraph
+            ellipsis={{
+              rows: 2,
+              showTooltip: { type: 'popover', opts: { style: { width: 240 } } },
+            }}
+            style={{ maxWidth: 240 }}
+          >
+            {text}
+          </Paragraph>
+        );
+      },
+    },
+  ];
 
   const [logs, setLogs] = useState([]);
   const [showStat, setShowStat] = useState(false);
@@ -154,12 +326,20 @@ const LogsTable = () => {
     model_name: '',
     start_timestamp: timestamp2string(now.getTime() / 1000 - 86400),
     end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),
-    channel: ''
+    channel: '',
   });
-  const { username, token_name, model_name, start_timestamp, end_timestamp, channel } = inputs;
+  const {
+    username,
+    token_name,
+    model_name,
+    start_timestamp,
+    end_timestamp,
+    channel,
+  } = inputs;
 
   const [stat, setStat] = useState({
-    quota: 0, token: 0
+    quota: 0,
+    token: 0,
   });
 
   const handleInputChange = (value, name) => {
@@ -169,7 +349,9 @@ const LogsTable = () => {
   const getLogSelfStat = async () => {
     let localStartTimestamp = Date.parse(start_timestamp) / 1000;
     let localEndTimestamp = Date.parse(end_timestamp) / 1000;
-    let res = await API.get(`/api/log/self/stat?type=${logType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`);
+    let res = await API.get(
+      `/api/log/self/stat?type=${logType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`,
+    );
     const { success, message, data } = res.data;
     if (success) {
       setStat(data);
@@ -181,7 +363,9 @@ const LogsTable = () => {
   const getLogStat = async () => {
     let localStartTimestamp = Date.parse(start_timestamp) / 1000;
     let localEndTimestamp = Date.parse(end_timestamp) / 1000;
-    let res = await API.get(`/api/log/stat?type=${logType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}`);
+    let res = await API.get(
+      `/api/log/stat?type=${logType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}`,
+    );
     const { success, message, data } = res.data;
     if (success) {
       setStat(data);
@@ -209,12 +393,16 @@ const LogsTable = () => {
     const { success, message, data } = res.data;
     if (success) {
       Modal.info({
-        title: '用户信息', content: <div style={{ padding: 12 }}>
-          <p>用户名: {data.username}</p>
-          <p>余额: {renderQuota(data.quota)}</p>
-          <p>已用额度:{renderQuota(data.used_quota)}</p>
-          <p>请求次数:{renderNumber(data.request_count)}</p>
-        </div>, centered: true
+        title: '用户信息',
+        content: (
+          <div style={{ padding: 12 }}>
+            <p>用户名: {data.username}</p>
+            <p>余额: {renderQuota(data.quota)}</p>
+            <p>已用额度:{renderQuota(data.used_quota)}</p>
+            <p>请求次数:{renderNumber(data.request_count)}</p>
+          </div>
+        ),
+        centered: true,
       });
     } else {
       showError(message);
@@ -259,14 +447,16 @@ const LogsTable = () => {
     setLoading(false);
   };
 
-  const pageData = logs.slice((activePage - 1) * pageSize, activePage * pageSize);
+  const pageData = logs.slice(
+    (activePage - 1) * pageSize,
+    activePage * pageSize,
+  );
 
-  const handlePageChange = page => {
+  const handlePageChange = (page) => {
     setActivePage(page);
     if (page === Math.ceil(logs.length / pageSize) + 1) {
       // In this case we have to load more data and then append them.
-      loadLogs(page - 1, pageSize, logType).then(r => {
-      });
+      loadLogs(page - 1, pageSize, logType).then((r) => {});
     }
   };
 
@@ -298,7 +488,8 @@ const LogsTable = () => {
 
   useEffect(() => {
     // console.log('default effect')
-    const localPageSize = parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
+    const localPageSize =
+      parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
     setPageSize(localPageSize);
     loadLogs(0, localPageSize)
       .then()
@@ -326,74 +517,136 @@ const LogsTable = () => {
     setSearching(false);
   };
 
-  return (<>
-    <Layout>
-      <Header>
-        <Spin spinning={loadingStat}>
-          <h3>使用明细(总消耗额度:
-            <span onClick={handleEyeClick} style={{
-              cursor: 'pointer', color: 'gray'
-            }}>{showStat ? renderQuota(stat.quota) : '点击查看'}</span>
-            )
-          </h3>
-        </Spin>
-      </Header>
-      <Form layout="horizontal" style={{ marginTop: 10 }}>
-        <>
-          <Form.Input field="token_name" label="令牌名称" style={{ width: 176 }} value={token_name}
-                      placeholder={'可选值'} name="token_name"
-                      onChange={value => handleInputChange(value, 'token_name')} />
-          <Form.Input field="model_name" label="模型名称" style={{ width: 176 }} value={model_name}
-                      placeholder="可选值"
-                      name="model_name"
-                      onChange={value => handleInputChange(value, 'model_name')} />
-          <Form.DatePicker field="start_timestamp" label="起始时间" style={{ width: 272 }}
-                           initValue={start_timestamp}
-                           value={start_timestamp} type="dateTime"
-                           name="start_timestamp"
-                           onChange={value => handleInputChange(value, 'start_timestamp')} />
-          <Form.DatePicker field="end_timestamp" fluid label="结束时间" style={{ width: 272 }}
-                           initValue={end_timestamp}
-                           value={end_timestamp} type="dateTime"
-                           name="end_timestamp"
-                           onChange={value => handleInputChange(value, 'end_timestamp')} />
-          {isAdminUser && <>
-            <Form.Input field="channel" label="渠道 ID" style={{ width: 176 }} value={channel}
-                        placeholder="可选值" name="channel"
-                        onChange={value => handleInputChange(value, 'channel')} />
-            <Form.Input field="username" label="用户名称" style={{ width: 176 }} value={username}
-                        placeholder={'可选值'} name="username"
-                        onChange={value => handleInputChange(value, 'username')} />
-          </>}
-          <Form.Section>
-            <Button label="查询" type="primary" htmlType="submit" className="btn-margin-right"
-                    onClick={refresh} loading={loading}>查询</Button>
-          </Form.Section>
-        </>
-      </Form>
-      <Table style={{ marginTop: 5 }} columns={columns} dataSource={pageData} pagination={{
-        currentPage: activePage,
-        pageSize: pageSize,
-        total: logCount,
-        pageSizeOpts: [10, 20, 50, 100],
-        showSizeChanger: true,
-        onPageSizeChange: (size) => {
-          handlePageSizeChange(size).then();
-        },
-        onPageChange: handlePageChange
-      }} />
-      <Select defaultValue="0" style={{ width: 120 }} onChange={(value) => {
-        setLogType(parseInt(value));
-        refresh(parseInt(value)).then();
-      }}>
-        <Select.Option value="0">全部</Select.Option>
-        <Select.Option value="1">充值</Select.Option>
-        <Select.Option value="2">消费</Select.Option>
-        <Select.Option value="3">管理</Select.Option>
-        <Select.Option value="4">系统</Select.Option>
-      </Select>
-    </Layout>
-  </>);
+  return (
+    <>
+      <Layout>
+        <Header>
+          <Spin spinning={loadingStat}>
+            <h3>
+              使用明细(总消耗额度:
+              <span
+                onClick={handleEyeClick}
+                style={{
+                  cursor: 'pointer',
+                  color: 'gray',
+                }}
+              >
+                {showStat ? renderQuota(stat.quota) : '点击查看'}
+              </span>
+              )
+            </h3>
+          </Spin>
+        </Header>
+        <Form layout='horizontal' style={{ marginTop: 10 }}>
+          <>
+            <Form.Input
+              field='token_name'
+              label='令牌名称'
+              style={{ width: 176 }}
+              value={token_name}
+              placeholder={'可选值'}
+              name='token_name'
+              onChange={(value) => handleInputChange(value, 'token_name')}
+            />
+            <Form.Input
+              field='model_name'
+              label='模型名称'
+              style={{ width: 176 }}
+              value={model_name}
+              placeholder='可选值'
+              name='model_name'
+              onChange={(value) => handleInputChange(value, 'model_name')}
+            />
+            <Form.DatePicker
+              field='start_timestamp'
+              label='起始时间'
+              style={{ width: 272 }}
+              initValue={start_timestamp}
+              value={start_timestamp}
+              type='dateTime'
+              name='start_timestamp'
+              onChange={(value) => handleInputChange(value, 'start_timestamp')}
+            />
+            <Form.DatePicker
+              field='end_timestamp'
+              fluid
+              label='结束时间'
+              style={{ width: 272 }}
+              initValue={end_timestamp}
+              value={end_timestamp}
+              type='dateTime'
+              name='end_timestamp'
+              onChange={(value) => handleInputChange(value, 'end_timestamp')}
+            />
+            {isAdminUser && (
+              <>
+                <Form.Input
+                  field='channel'
+                  label='渠道 ID'
+                  style={{ width: 176 }}
+                  value={channel}
+                  placeholder='可选值'
+                  name='channel'
+                  onChange={(value) => handleInputChange(value, 'channel')}
+                />
+                <Form.Input
+                  field='username'
+                  label='用户名称'
+                  style={{ width: 176 }}
+                  value={username}
+                  placeholder={'可选值'}
+                  name='username'
+                  onChange={(value) => handleInputChange(value, 'username')}
+                />
+              </>
+            )}
+            <Form.Section>
+              <Button
+                label='查询'
+                type='primary'
+                htmlType='submit'
+                className='btn-margin-right'
+                onClick={refresh}
+                loading={loading}
+              >
+                查询
+              </Button>
+            </Form.Section>
+          </>
+        </Form>
+        <Table
+          style={{ marginTop: 5 }}
+          columns={columns}
+          dataSource={pageData}
+          pagination={{
+            currentPage: activePage,
+            pageSize: pageSize,
+            total: logCount,
+            pageSizeOpts: [10, 20, 50, 100],
+            showSizeChanger: true,
+            onPageSizeChange: (size) => {
+              handlePageSizeChange(size).then();
+            },
+            onPageChange: handlePageChange,
+          }}
+        />
+        <Select
+          defaultValue='0'
+          style={{ width: 120 }}
+          onChange={(value) => {
+            setLogType(parseInt(value));
+            refresh(parseInt(value)).then();
+          }}
+        >
+          <Select.Option value='0'>全部</Select.Option>
+          <Select.Option value='1'>充值</Select.Option>
+          <Select.Option value='2'>消费</Select.Option>
+          <Select.Option value='3'>管理</Select.Option>
+          <Select.Option value='4'>系统</Select.Option>
+        </Select>
+      </Layout>
+    </>
+  );
 };
 
 export default LogsTable;

+ 294 - 127
web/src/components/MjLogsTable.js

@@ -1,86 +1,226 @@
 import React, { useEffect, useState } from 'react';
-import { API, copy, isAdmin, showError, showSuccess, timestamp2string } from '../helpers';
-
-import { Banner, Button, Form, ImagePreview, Layout, Modal, Progress, Table, Tag, Typography } from '@douyinfe/semi-ui';
+import {
+  API,
+  copy,
+  isAdmin,
+  showError,
+  showSuccess,
+  timestamp2string,
+} from '../helpers';
+
+import {
+  Banner,
+  Button,
+  Form,
+  ImagePreview,
+  Layout,
+  Modal,
+  Progress,
+  Table,
+  Tag,
+  Typography,
+} from '@douyinfe/semi-ui';
 import { ITEMS_PER_PAGE } from '../constants';
 
-
-const colors = ['amber', 'blue', 'cyan', 'green', 'grey', 'indigo',
-  'light-blue', 'lime', 'orange', 'pink',
-  'purple', 'red', 'teal', 'violet', 'yellow'
+const colors = [
+  'amber',
+  'blue',
+  'cyan',
+  'green',
+  'grey',
+  'indigo',
+  'light-blue',
+  'lime',
+  'orange',
+  'pink',
+  'purple',
+  'red',
+  'teal',
+  'violet',
+  'yellow',
 ];
 
 function renderType(type) {
   switch (type) {
     case 'IMAGINE':
-      return <Tag color="blue" size="large">绘图</Tag>;
+      return (
+        <Tag color='blue' size='large'>
+          绘图
+        </Tag>
+      );
     case 'UPSCALE':
-      return <Tag color="orange" size="large">放大</Tag>;
+      return (
+        <Tag color='orange' size='large'>
+          放大
+        </Tag>
+      );
     case 'VARIATION':
-      return <Tag color="purple" size="large">变换</Tag>;
+      return (
+        <Tag color='purple' size='large'>
+          变换
+        </Tag>
+      );
     case 'HIGH_VARIATION':
-      return <Tag color="purple" size="large">强变换</Tag>;
+      return (
+        <Tag color='purple' size='large'>
+          强变换
+        </Tag>
+      );
     case 'LOW_VARIATION':
-      return <Tag color="purple" size="large">弱变换</Tag>;
+      return (
+        <Tag color='purple' size='large'>
+          弱变换
+        </Tag>
+      );
     case 'PAN':
-      return <Tag color="cyan" size="large">平移</Tag>;
+      return (
+        <Tag color='cyan' size='large'>
+          平移
+        </Tag>
+      );
     case 'DESCRIBE':
-      return <Tag color="yellow" size="large">图生文</Tag>;
+      return (
+        <Tag color='yellow' size='large'>
+          图生文
+        </Tag>
+      );
     case 'BLEND':
-      return <Tag color="lime" size="large">图混合</Tag>;
+      return (
+        <Tag color='lime' size='large'>
+          图混合
+        </Tag>
+      );
     case 'SHORTEN':
-      return <Tag color="pink" size="large">缩词</Tag>;
+      return (
+        <Tag color='pink' size='large'>
+          缩词
+        </Tag>
+      );
     case 'REROLL':
-      return <Tag color="indigo" size="large">重绘</Tag>;
+      return (
+        <Tag color='indigo' size='large'>
+          重绘
+        </Tag>
+      );
     case 'INPAINT':
-      return <Tag color="violet" size="large">局部重绘-提交</Tag>;
+      return (
+        <Tag color='violet' size='large'>
+          局部重绘-提交
+        </Tag>
+      );
     case 'ZOOM':
-      return <Tag color="teal" size="large">变焦</Tag>;
+      return (
+        <Tag color='teal' size='large'>
+          变焦
+        </Tag>
+      );
     case 'CUSTOM_ZOOM':
-      return <Tag color="teal" size="large">自定义变焦-提交</Tag>;
+      return (
+        <Tag color='teal' size='large'>
+          自定义变焦-提交
+        </Tag>
+      );
     case 'MODAL':
-      return <Tag color="green" size="large">窗口处理</Tag>;
+      return (
+        <Tag color='green' size='large'>
+          窗口处理
+        </Tag>
+      );
     case 'SWAP_FACE':
-      return <Tag color="light-green" size="large">换脸</Tag>;
+      return (
+        <Tag color='light-green' size='large'>
+          换脸
+        </Tag>
+      );
     default:
-      return <Tag color="white" size="large">未知</Tag>;
+      return (
+        <Tag color='white' size='large'>
+          未知
+        </Tag>
+      );
   }
 }
 
-
 function renderCode(code) {
   switch (code) {
     case 1:
-      return <Tag color="green" size="large">已提交</Tag>;
+      return (
+        <Tag color='green' size='large'>
+          已提交
+        </Tag>
+      );
     case 21:
-      return <Tag color="lime" size="large">等待中</Tag>;
+      return (
+        <Tag color='lime' size='large'>
+          等待中
+        </Tag>
+      );
     case 22:
-      return <Tag color="orange" size="large">重复提交</Tag>;
+      return (
+        <Tag color='orange' size='large'>
+          重复提交
+        </Tag>
+      );
     case 0:
-      return <Tag color="yellow" size="large">未提交</Tag>;
+      return (
+        <Tag color='yellow' size='large'>
+          未提交
+        </Tag>
+      );
     default:
-      return <Tag color="white" size="large">未知</Tag>;
+      return (
+        <Tag color='white' size='large'>
+          未知
+        </Tag>
+      );
   }
 }
 
-
 function renderStatus(type) {
   // Ensure all cases are string literals by adding quotes.
   switch (type) {
     case 'SUCCESS':
-      return <Tag color="green" size="large">成功</Tag>;
+      return (
+        <Tag color='green' size='large'>
+          成功
+        </Tag>
+      );
     case 'NOT_START':
-      return <Tag color="grey" size="large">未启动</Tag>;
+      return (
+        <Tag color='grey' size='large'>
+          未启动
+        </Tag>
+      );
     case 'SUBMITTED':
-      return <Tag color="yellow" size="large">队列中</Tag>;
+      return (
+        <Tag color='yellow' size='large'>
+          队列中
+        </Tag>
+      );
     case 'IN_PROGRESS':
-      return <Tag color="blue" size="large">执行中</Tag>;
+      return (
+        <Tag color='blue' size='large'>
+          执行中
+        </Tag>
+      );
     case 'FAILURE':
-      return <Tag color="red" size="large">失败</Tag>;
+      return (
+        <Tag color='red' size='large'>
+          失败
+        </Tag>
+      );
     case 'MODAL':
-      return <Tag color="yellow" size="large">窗口等待</Tag>;
+      return (
+        <Tag color='yellow' size='large'>
+          窗口等待
+        </Tag>
+      );
     default:
-      return <Tag color="white" size="large">未知</Tag>;
+      return (
+        <Tag color='white' size='large'>
+          未知
+        </Tag>
+      );
   }
 }
 
@@ -97,7 +237,6 @@ const renderTimestamp = (timestampInSeconds) => {
   return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; // 格式化输出
 };
 
-
 const LogsTable = () => {
   const [isModalOpen, setIsModalOpen] = useState(false);
   const [modalContent, setModalContent] = useState('');
@@ -106,12 +245,8 @@ const LogsTable = () => {
       title: '提交时间',
       dataIndex: 'submit_time',
       render: (text, record, index) => {
-        return (
-          <div>
-            {renderTimestamp(text / 1000)}
-          </div>
-        );
-      }
+        return <div>{renderTimestamp(text / 1000)}</div>;
+      },
     },
     {
       title: '渠道',
@@ -119,61 +254,50 @@ const LogsTable = () => {
       className: isAdmin() ? 'tableShow' : 'tableHiddle',
       render: (text, record, index) => {
         return (
-
           <div>
-            <Tag color={colors[parseInt(text) % colors.length]} size="large" onClick={() => {
-              copyText(text); // 假设copyText是用于文本复制的函数
-            }}> {text} </Tag>
+            <Tag
+              color={colors[parseInt(text) % colors.length]}
+              size='large'
+              onClick={() => {
+                copyText(text); // 假设copyText是用于文本复制的函数
+              }}
+            >
+              {' '}
+              {text}{' '}
+            </Tag>
           </div>
-
         );
-      }
+      },
     },
     {
       title: '类型',
       dataIndex: 'action',
       render: (text, record, index) => {
-        return (
-          <div>
-            {renderType(text)}
-          </div>
-        );
-      }
+        return <div>{renderType(text)}</div>;
+      },
     },
     {
       title: '任务ID',
       dataIndex: 'mj_id',
       render: (text, record, index) => {
-        return (
-          <div>
-            {text}
-          </div>
-        );
-      }
+        return <div>{text}</div>;
+      },
     },
     {
       title: '提交结果',
       dataIndex: 'code',
       className: isAdmin() ? 'tableShow' : 'tableHiddle',
       render: (text, record, index) => {
-        return (
-          <div>
-            {renderCode(text)}
-          </div>
-        );
-      }
+        return <div>{renderCode(text)}</div>;
+      },
     },
     {
       title: '任务状态',
       dataIndex: 'status',
       className: isAdmin() ? 'tableShow' : 'tableHiddle',
       render: (text, record, index) => {
-        return (
-          <div>
-            {renderStatus(text)}
-          </div>
-        );
-      }
+        return <div>{renderStatus(text)}</div>;
+      },
     },
     {
       title: '进度',
@@ -183,13 +307,20 @@ const LogsTable = () => {
           <div>
             {
               // 转换例如100%为数字100,如果text未定义,返回0
-              <Progress stroke={record.status === 'FAILURE' ? 'var(--semi-color-warning)' : null}
-                        percent={text ? parseInt(text.replace('%', '')) : 0} showInfo={true}
-                        aria-label="drawing progress" />
+              <Progress
+                stroke={
+                  record.status === 'FAILURE'
+                    ? 'var(--semi-color-warning)'
+                    : null
+                }
+                percent={text ? parseInt(text.replace('%', '')) : 0}
+                showInfo={true}
+                aria-label='drawing progress'
+              />
             }
           </div>
         );
-      }
+      },
     },
     {
       title: '结果图片',
@@ -201,14 +332,14 @@ const LogsTable = () => {
         return (
           <Button
             onClick={() => {
-              setModalImageUrl(text);  // 更新图片URL状态
-              setIsModalOpenurl(true);    // 打开模态框
+              setModalImageUrl(text); // 更新图片URL状态
+              setIsModalOpenurl(true); // 打开模态框
             }}
           >
             查看图片
           </Button>
         );
-      }
+      },
     },
     {
       title: 'Prompt',
@@ -231,7 +362,7 @@ const LogsTable = () => {
             {text}
           </Typography.Text>
         );
-      }
+      },
     },
     {
       title: 'PromptEn',
@@ -254,7 +385,7 @@ const LogsTable = () => {
             {text}
           </Typography.Text>
         );
-      }
+      },
     },
     {
       title: '失败原因',
@@ -277,9 +408,8 @@ const LogsTable = () => {
             {text}
           </Typography.Text>
         );
-      }
-    }
-
+      },
+    },
   ];
 
   const [logs, setLogs] = useState([]);
@@ -299,20 +429,19 @@ const LogsTable = () => {
     channel_id: '',
     mj_id: '',
     start_timestamp: timestamp2string(now.getTime() / 1000 - 2592000),
-    end_timestamp: timestamp2string(now.getTime() / 1000 + 3600)
+    end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),
   });
   const { channel_id, mj_id, start_timestamp, end_timestamp } = inputs;
 
   const [stat, setStat] = useState({
     quota: 0,
-    token: 0
+    token: 0,
   });
 
   const handleInputChange = (value, name) => {
     setInputs((inputs) => ({ ...inputs, [name]: value }));
   };
 
-
   const setLogsFormat = (logs) => {
     for (let i = 0; i < logs.length; i++) {
       logs[i].timestamp2string = timestamp2string(logs[i].created_at);
@@ -351,14 +480,16 @@ const LogsTable = () => {
     setLoading(false);
   };
 
-  const pageData = logs.slice((activePage - 1) * ITEMS_PER_PAGE, activePage * ITEMS_PER_PAGE);
+  const pageData = logs.slice(
+    (activePage - 1) * ITEMS_PER_PAGE,
+    activePage * ITEMS_PER_PAGE,
+  );
 
-  const handlePageChange = page => {
+  const handlePageChange = (page) => {
     setActivePage(page);
     if (page === Math.ceil(logs.length / ITEMS_PER_PAGE) + 1) {
       // In this case we have to load more data and then append them.
-      loadLogs(page - 1).then(r => {
-      });
+      loadLogs(page - 1).then((r) => {});
     }
   };
 
@@ -390,46 +521,83 @@ const LogsTable = () => {
 
   return (
     <>
-
       <Layout>
-        {isAdminUser && showBanner ? <Banner
-          type="info"
-          description="当前未开启Midjourney回调,部分项目可能无法获得绘图结果,可在运营设置中开启。"
-        /> : <></>
-        }
-        <Form layout="horizontal" style={{ marginTop: 10 }}>
+        {isAdminUser && showBanner ? (
+          <Banner
+            type='info'
+            description='当前未开启Midjourney回调,部分项目可能无法获得绘图结果,可在运营设置中开启。'
+          />
+        ) : (
+          <></>
+        )}
+        <Form layout='horizontal' style={{ marginTop: 10 }}>
           <>
-            <Form.Input field="channel_id" label="渠道 ID" style={{ width: 176 }} value={channel_id}
-                        placeholder={'可选值'} name="channel_id"
-                        onChange={value => handleInputChange(value, 'channel_id')} />
-            <Form.Input field="mj_id" label="任务 ID" style={{ width: 176 }} value={mj_id}
-                        placeholder="可选值"
-                        name="mj_id"
-                        onChange={value => handleInputChange(value, 'mj_id')} />
-            <Form.DatePicker field="start_timestamp" label="起始时间" style={{ width: 272 }}
-                             initValue={start_timestamp}
-                             value={start_timestamp} type="dateTime"
-                             name="start_timestamp"
-                             onChange={value => handleInputChange(value, 'start_timestamp')} />
-            <Form.DatePicker field="end_timestamp" fluid label="结束时间" style={{ width: 272 }}
-                             initValue={end_timestamp}
-                             value={end_timestamp} type="dateTime"
-                             name="end_timestamp"
-                             onChange={value => handleInputChange(value, 'end_timestamp')} />
+            <Form.Input
+              field='channel_id'
+              label='渠道 ID'
+              style={{ width: 176 }}
+              value={channel_id}
+              placeholder={'可选值'}
+              name='channel_id'
+              onChange={(value) => handleInputChange(value, 'channel_id')}
+            />
+            <Form.Input
+              field='mj_id'
+              label='任务 ID'
+              style={{ width: 176 }}
+              value={mj_id}
+              placeholder='可选值'
+              name='mj_id'
+              onChange={(value) => handleInputChange(value, 'mj_id')}
+            />
+            <Form.DatePicker
+              field='start_timestamp'
+              label='起始时间'
+              style={{ width: 272 }}
+              initValue={start_timestamp}
+              value={start_timestamp}
+              type='dateTime'
+              name='start_timestamp'
+              onChange={(value) => handleInputChange(value, 'start_timestamp')}
+            />
+            <Form.DatePicker
+              field='end_timestamp'
+              fluid
+              label='结束时间'
+              style={{ width: 272 }}
+              initValue={end_timestamp}
+              value={end_timestamp}
+              type='dateTime'
+              name='end_timestamp'
+              onChange={(value) => handleInputChange(value, 'end_timestamp')}
+            />
 
             <Form.Section>
-              <Button label="查询" type="primary" htmlType="submit" className="btn-margin-right"
-                      onClick={refresh}>查询</Button>
+              <Button
+                label='查询'
+                type='primary'
+                htmlType='submit'
+                className='btn-margin-right'
+                onClick={refresh}
+              >
+                查询
+              </Button>
             </Form.Section>
           </>
         </Form>
-        <Table style={{ marginTop: 5 }} columns={columns} dataSource={pageData} pagination={{
-          currentPage: activePage,
-          pageSize: ITEMS_PER_PAGE,
-          total: logCount,
-          pageSizeOpts: [10, 20, 50, 100],
-          onPageChange: handlePageChange
-        }} loading={loading} />
+        <Table
+          style={{ marginTop: 5 }}
+          columns={columns}
+          dataSource={pageData}
+          pagination={{
+            currentPage: activePage,
+            pageSize: ITEMS_PER_PAGE,
+            total: logCount,
+            pageSizeOpts: [10, 20, 50, 100],
+            onPageChange: handlePageChange,
+          }}
+          loading={loading}
+        />
         <Modal
           visible={isModalOpen}
           onOk={() => setIsModalOpen(false)}
@@ -445,7 +613,6 @@ const LogsTable = () => {
           visible={isModalOpenurl}
           onVisibleChange={(visible) => setIsModalOpenurl(visible)}
         />
-
       </Layout>
     </>
   );

+ 219 - 178
web/src/components/OperationSetting.js

@@ -1,6 +1,12 @@
 import React, { useEffect, useState } from 'react';
 import { Divider, Form, Grid, Header } from 'semantic-ui-react';
-import { API, showError, showSuccess, timestamp2string, verifyJSON } from '../helpers';
+import {
+  API,
+  showError,
+  showSuccess,
+  timestamp2string,
+  verifyJSON,
+} from '../helpers';
 
 const OperationSetting = () => {
   let now = new Date();
@@ -35,16 +41,18 @@ const OperationSetting = () => {
     DataExportDefaultTime: 'hour',
     DataExportInterval: 5,
     DefaultCollapseSidebar: '', // 默认折叠侧边栏
-    RetryTimes: 0
+    RetryTimes: 0,
   });
   const [originInputs, setOriginInputs] = useState({});
   let [loading, setLoading] = useState(false);
-  let [historyTimestamp, setHistoryTimestamp] = useState(timestamp2string(now.getTime() / 1000 - 30 * 24 * 3600)); // a month ago
+  let [historyTimestamp, setHistoryTimestamp] = useState(
+    timestamp2string(now.getTime() / 1000 - 30 * 24 * 3600),
+  ); // a month ago
   // 精确时间选项(小时,天,周)
   const timeOptions = [
     { key: 'hour', text: '小时', value: 'hour' },
     { key: 'day', text: '天', value: 'day' },
-    { key: 'week', text: '周', value: 'week' }
+    { key: 'week', text: '周', value: 'week' },
   ];
   const getOptions = async () => {
     const res = await API.get('/api/option/');
@@ -52,7 +60,11 @@ const OperationSetting = () => {
     if (success) {
       let newInputs = {};
       data.forEach((item) => {
-        if (item.key === 'ModelRatio' || item.key === 'GroupRatio' || item.key === 'ModelPrice') {
+        if (
+          item.key === 'ModelRatio' ||
+          item.key === 'GroupRatio' ||
+          item.key === 'ModelPrice'
+        ) {
           item.value = JSON.stringify(JSON.parse(item.value), null, 2);
         }
         newInputs[item.key] = item.value;
@@ -79,7 +91,7 @@ const OperationSetting = () => {
     console.log(key, value);
     const res = await API.put('/api/option/', {
       key,
-      value
+      value,
     });
     const { success, message } = res.data;
     if (success) {
@@ -91,7 +103,12 @@ const OperationSetting = () => {
   };
 
   const handleInputChange = async (e, { name, value }) => {
-    if (name.endsWith('Enabled') || name === 'DataExportInterval' || name === 'DataExportDefaultTime' || name === 'DefaultCollapseSidebar') {
+    if (
+      name.endsWith('Enabled') ||
+      name === 'DataExportInterval' ||
+      name === 'DataExportDefaultTime' ||
+      name === 'DefaultCollapseSidebar'
+    ) {
       if (name === 'DataExportDefaultTime') {
         localStorage.setItem('data_export_default_time', value);
       } else if (name === 'MjNotifyEnabled') {
@@ -106,11 +123,22 @@ const OperationSetting = () => {
   const submitConfig = async (group) => {
     switch (group) {
       case 'monitor':
-        if (originInputs['ChannelDisableThreshold'] !== inputs.ChannelDisableThreshold) {
-          await updateOption('ChannelDisableThreshold', inputs.ChannelDisableThreshold);
+        if (
+          originInputs['ChannelDisableThreshold'] !==
+          inputs.ChannelDisableThreshold
+        ) {
+          await updateOption(
+            'ChannelDisableThreshold',
+            inputs.ChannelDisableThreshold,
+          );
         }
-        if (originInputs['QuotaRemindThreshold'] !== inputs.QuotaRemindThreshold) {
-          await updateOption('QuotaRemindThreshold', inputs.QuotaRemindThreshold);
+        if (
+          originInputs['QuotaRemindThreshold'] !== inputs.QuotaRemindThreshold
+        ) {
+          await updateOption(
+            'QuotaRemindThreshold',
+            inputs.QuotaRemindThreshold,
+          );
         }
         break;
       case 'ratio':
@@ -177,7 +205,9 @@ const OperationSetting = () => {
 
   const deleteHistoryLogs = async () => {
     console.log(inputs);
-    const res = await API.delete(`/api/log/?target_timestamp=${Date.parse(historyTimestamp) / 1000}`);
+    const res = await API.delete(
+      `/api/log/?target_timestamp=${Date.parse(historyTimestamp) / 1000}`,
+    );
     const { success, message, data } = res.data;
     if (success) {
       showSuccess(`${data} 条日志已清理!`);
@@ -189,131 +219,129 @@ const OperationSetting = () => {
     <Grid columns={1}>
       <Grid.Column>
         <Form loading={loading}>
-          <Header as="h3">
-            通用设置
-          </Header>
+          <Header as='h3'>通用设置</Header>
           <Form.Group widths={4}>
             <Form.Input
-              label="充值链接"
-              name="TopUpLink"
+              label='充值链接'
+              name='TopUpLink'
               onChange={handleInputChange}
-              autoComplete="new-password"
+              autoComplete='new-password'
               value={inputs.TopUpLink}
-              type="link"
-              placeholder="例如发卡网站的购买链接"
+              type='link'
+              placeholder='例如发卡网站的购买链接'
             />
             <Form.Input
-              label="默认聊天页面链接"
-              name="ChatLink"
+              label='默认聊天页面链接'
+              name='ChatLink'
               onChange={handleInputChange}
-              autoComplete="new-password"
+              autoComplete='new-password'
               value={inputs.ChatLink}
-              type="link"
-              placeholder="例如 ChatGPT Next Web 的部署地址"
+              type='link'
+              placeholder='例如 ChatGPT Next Web 的部署地址'
             />
             <Form.Input
-              label="聊天页面2链接"
-              name="ChatLink2"
+              label='聊天页面2链接'
+              name='ChatLink2'
               onChange={handleInputChange}
-              autoComplete="new-password"
+              autoComplete='new-password'
               value={inputs.ChatLink2}
-              type="link"
-              placeholder="例如 ChatGPT Web & Midjourney 的部署地址"
+              type='link'
+              placeholder='例如 ChatGPT Web & Midjourney 的部署地址'
             />
             <Form.Input
-              label="单位美元额度"
-              name="QuotaPerUnit"
+              label='单位美元额度'
+              name='QuotaPerUnit'
               onChange={handleInputChange}
-              autoComplete="new-password"
+              autoComplete='new-password'
               value={inputs.QuotaPerUnit}
-              type="number"
-              step="0.01"
-              placeholder="一单位货币能兑换的额度"
+              type='number'
+              step='0.01'
+              placeholder='一单位货币能兑换的额度'
             />
             <Form.Input
-              label="失败重试次数"
-              name="RetryTimes"
+              label='失败重试次数'
+              name='RetryTimes'
               type={'number'}
-              step="1"
-              min="0"
+              step='1'
+              min='0'
               onChange={handleInputChange}
-              autoComplete="new-password"
+              autoComplete='new-password'
               value={inputs.RetryTimes}
-              placeholder="失败重试次数"
+              placeholder='失败重试次数'
             />
           </Form.Group>
           <Form.Group inline>
             <Form.Checkbox
               checked={inputs.DisplayInCurrencyEnabled === 'true'}
-              label="以货币形式显示额度"
-              name="DisplayInCurrencyEnabled"
+              label='以货币形式显示额度'
+              name='DisplayInCurrencyEnabled'
               onChange={handleInputChange}
             />
             <Form.Checkbox
               checked={inputs.DisplayTokenStatEnabled === 'true'}
-              label="Billing 相关 API 显示令牌额度而非用户额度"
-              name="DisplayTokenStatEnabled"
+              label='Billing 相关 API 显示令牌额度而非用户额度'
+              name='DisplayTokenStatEnabled'
               onChange={handleInputChange}
             />
             <Form.Checkbox
               checked={inputs.DefaultCollapseSidebar === 'true'}
-              label="默认折叠侧边栏"
-              name="DefaultCollapseSidebar"
+              label='默认折叠侧边栏'
+              name='DefaultCollapseSidebar'
               onChange={handleInputChange}
             />
           </Form.Group>
-          <Form.Button onClick={() => {
-            submitConfig('general').then();
-          }}>保存通用设置</Form.Button>
+          <Form.Button
+            onClick={() => {
+              submitConfig('general').then();
+            }}
+          >
+            保存通用设置
+          </Form.Button>
           <Divider />
-          <Header as="h3">
-            绘图设置
-          </Header>
+          <Header as='h3'>绘图设置</Header>
           <Form.Group inline>
             <Form.Checkbox
               checked={inputs.DrawingEnabled === 'true'}
-              label="启用绘图功能"
-              name="DrawingEnabled"
+              label='启用绘图功能'
+              name='DrawingEnabled'
               onChange={handleInputChange}
             />
             <Form.Checkbox
               checked={inputs.MjNotifyEnabled === 'true'}
-              label="允许回调(会泄露服务器ip地址)"
-              name="MjNotifyEnabled"
+              label='允许回调(会泄露服务器ip地址)'
+              name='MjNotifyEnabled'
               onChange={handleInputChange}
             />
           </Form.Group>
           <Divider />
-          <Header as="h3">
-            屏蔽词过滤设置
-          </Header>
+          <Header as='h3'>屏蔽词过滤设置</Header>
           <Form.Group inline>
             <Form.Checkbox
               checked={inputs.CheckSensitiveEnabled === 'true'}
-              label="启用屏蔽词过滤功能"
-              name="CheckSensitiveEnabled"
+              label='启用屏蔽词过滤功能'
+              name='CheckSensitiveEnabled'
               onChange={handleInputChange}
             />
           </Form.Group>
           <Form.Group inline>
             <Form.Checkbox
               checked={inputs.CheckSensitiveOnPromptEnabled === 'true'}
-              label="启用prompt检查"
-              name="CheckSensitiveOnPromptEnabled"
+              label='启用prompt检查'
+              name='CheckSensitiveOnPromptEnabled'
               onChange={handleInputChange}
             />
             <Form.Checkbox
               checked={inputs.CheckSensitiveOnCompletionEnabled === 'true'}
-              label="启用生成内容检查"
-              name="CheckSensitiveOnCompletionEnabled"
+              label='启用生成内容检查'
+              name='CheckSensitiveOnCompletionEnabled'
               onChange={handleInputChange}
             />
           </Form.Group>
           <Form.Group inline>
             <Form.Checkbox
               checked={inputs.StopOnSensitiveEnabled === 'true'}
-              label="在检测到屏蔽词时,立刻停止生成,否则替换屏蔽词"
-              name="StopOnSensitiveEnabled"
+              label='在检测到屏蔽词时,立刻停止生成,否则替换屏蔽词'
+              name='StopOnSensitiveEnabled'
               onChange={handleInputChange}
             />
           </Form.Group>
@@ -328,210 +356,223 @@ const OperationSetting = () => {
           {/*    placeholder="例如:10"*/}
           {/*  />*/}
           {/*</Form.Group>*/}
-          <Form.Group widths="equal">
+          <Form.Group widths='equal'>
             <Form.TextArea
-              label="屏蔽词列表,一行一个屏蔽词,不需要符号分割"
-              name="SensitiveWords"
+              label='屏蔽词列表,一行一个屏蔽词,不需要符号分割'
+              name='SensitiveWords'
               onChange={handleInputChange}
               style={{ minHeight: 250, fontFamily: 'JetBrains Mono, Consolas' }}
               value={inputs.SensitiveWords}
-              placeholder="一行一个屏蔽词"
+              placeholder='一行一个屏蔽词'
             />
           </Form.Group>
-          <Form.Button onClick={() => {
-            submitConfig('words').then();
-          }}>保存屏蔽词设置</Form.Button>
+          <Form.Button
+            onClick={() => {
+              submitConfig('words').then();
+            }}
+          >
+            保存屏蔽词设置
+          </Form.Button>
           <Divider />
-          <Header as="h3">
-            日志设置
-          </Header>
+          <Header as='h3'>日志设置</Header>
           <Form.Group inline>
             <Form.Checkbox
               checked={inputs.LogConsumeEnabled === 'true'}
-              label="启用额度消费日志记录"
-              name="LogConsumeEnabled"
+              label='启用额度消费日志记录'
+              name='LogConsumeEnabled'
               onChange={handleInputChange}
             />
           </Form.Group>
           <Form.Group widths={4}>
-            <Form.Input label="目标时间" value={historyTimestamp} type="datetime-local"
-                        name="history_timestamp"
-                        onChange={(e, { name, value }) => {
-                          setHistoryTimestamp(value);
-                        }} />
+            <Form.Input
+              label='目标时间'
+              value={historyTimestamp}
+              type='datetime-local'
+              name='history_timestamp'
+              onChange={(e, { name, value }) => {
+                setHistoryTimestamp(value);
+              }}
+            />
           </Form.Group>
-          <Form.Button onClick={() => {
-            deleteHistoryLogs().then();
-          }}>清理历史日志</Form.Button>
+          <Form.Button
+            onClick={() => {
+              deleteHistoryLogs().then();
+            }}
+          >
+            清理历史日志
+          </Form.Button>
           <Divider />
-          <Header as="h3">
-            数据看板
-          </Header>
+          <Header as='h3'>数据看板</Header>
           <Form.Checkbox
             checked={inputs.DataExportEnabled === 'true'}
-            label="启用数据看板(实验性)"
-            name="DataExportEnabled"
+            label='启用数据看板(实验性)'
+            name='DataExportEnabled'
             onChange={handleInputChange}
           />
           <Form.Group>
             <Form.Input
-              label="数据看板更新间隔(分钟,设置过短会影响数据库性能)"
-              name="DataExportInterval"
+              label='数据看板更新间隔(分钟,设置过短会影响数据库性能)'
+              name='DataExportInterval'
               type={'number'}
-              step="1"
-              min="1"
+              step='1'
+              min='1'
               onChange={handleInputChange}
-              autoComplete="new-password"
+              autoComplete='new-password'
               value={inputs.DataExportInterval}
-              placeholder="数据看板更新间隔(分钟,设置过短会影响数据库性能)"
+              placeholder='数据看板更新间隔(分钟,设置过短会影响数据库性能)'
             />
             <Form.Select
-              label="数据看板默认时间粒度(仅修改展示粒度,统计精确到小时)"
+              label='数据看板默认时间粒度(仅修改展示粒度,统计精确到小时)'
               options={timeOptions}
-              name="DataExportDefaultTime"
+              name='DataExportDefaultTime'
               onChange={handleInputChange}
-              autoComplete="new-password"
+              autoComplete='new-password'
               value={inputs.DataExportDefaultTime}
-              placeholder="数据看板默认时间粒度"
+              placeholder='数据看板默认时间粒度'
             />
           </Form.Group>
           <Divider />
-          <Header as="h3">
-            监控设置
-          </Header>
+          <Header as='h3'>监控设置</Header>
           <Form.Group widths={3}>
             <Form.Input
-              label="最长响应时间"
-              name="ChannelDisableThreshold"
+              label='最长响应时间'
+              name='ChannelDisableThreshold'
               onChange={handleInputChange}
-              autoComplete="new-password"
+              autoComplete='new-password'
               value={inputs.ChannelDisableThreshold}
-              type="number"
-              min="0"
-              placeholder="单位秒,当运行通道全部测试时,超过此时间将自动禁用通道"
+              type='number'
+              min='0'
+              placeholder='单位秒,当运行通道全部测试时,超过此时间将自动禁用通道'
             />
             <Form.Input
-              label="额度提醒阈值"
-              name="QuotaRemindThreshold"
+              label='额度提醒阈值'
+              name='QuotaRemindThreshold'
               onChange={handleInputChange}
-              autoComplete="new-password"
+              autoComplete='new-password'
               value={inputs.QuotaRemindThreshold}
-              type="number"
-              min="0"
-              placeholder="低于此额度时将发送邮件提醒用户"
+              type='number'
+              min='0'
+              placeholder='低于此额度时将发送邮件提醒用户'
             />
           </Form.Group>
           <Form.Group inline>
             <Form.Checkbox
               checked={inputs.AutomaticDisableChannelEnabled === 'true'}
-              label="失败时自动禁用通道"
-              name="AutomaticDisableChannelEnabled"
+              label='失败时自动禁用通道'
+              name='AutomaticDisableChannelEnabled'
               onChange={handleInputChange}
             />
             <Form.Checkbox
               checked={inputs.AutomaticEnableChannelEnabled === 'true'}
-              label="成功时自动启用通道"
-              name="AutomaticEnableChannelEnabled"
+              label='成功时自动启用通道'
+              name='AutomaticEnableChannelEnabled'
               onChange={handleInputChange}
             />
           </Form.Group>
-          <Form.Button onClick={() => {
-            submitConfig('monitor').then();
-          }}>保存监控设置</Form.Button>
+          <Form.Button
+            onClick={() => {
+              submitConfig('monitor').then();
+            }}
+          >
+            保存监控设置
+          </Form.Button>
           <Divider />
-          <Header as="h3">
-            额度设置
-          </Header>
+          <Header as='h3'>额度设置</Header>
           <Form.Group widths={4}>
             <Form.Input
-              label="新用户初始额度"
-              name="QuotaForNewUser"
+              label='新用户初始额度'
+              name='QuotaForNewUser'
               onChange={handleInputChange}
-              autoComplete="new-password"
+              autoComplete='new-password'
               value={inputs.QuotaForNewUser}
-              type="number"
-              min="0"
-              placeholder="例如:100"
+              type='number'
+              min='0'
+              placeholder='例如:100'
             />
             <Form.Input
-              label="请求预扣费额度"
-              name="PreConsumedQuota"
+              label='请求预扣费额度'
+              name='PreConsumedQuota'
               onChange={handleInputChange}
-              autoComplete="new-password"
+              autoComplete='new-password'
               value={inputs.PreConsumedQuota}
-              type="number"
-              min="0"
-              placeholder="请求结束后多退少补"
+              type='number'
+              min='0'
+              placeholder='请求结束后多退少补'
             />
             <Form.Input
-              label="邀请新用户奖励额度"
-              name="QuotaForInviter"
+              label='邀请新用户奖励额度'
+              name='QuotaForInviter'
               onChange={handleInputChange}
-              autoComplete="new-password"
+              autoComplete='new-password'
               value={inputs.QuotaForInviter}
-              type="number"
-              min="0"
-              placeholder="例如:2000"
+              type='number'
+              min='0'
+              placeholder='例如:2000'
             />
             <Form.Input
-              label="新用户使用邀请码奖励额度"
-              name="QuotaForInvitee"
+              label='新用户使用邀请码奖励额度'
+              name='QuotaForInvitee'
               onChange={handleInputChange}
-              autoComplete="new-password"
+              autoComplete='new-password'
               value={inputs.QuotaForInvitee}
-              type="number"
-              min="0"
-              placeholder="例如:1000"
+              type='number'
+              min='0'
+              placeholder='例如:1000'
             />
           </Form.Group>
-          <Form.Button onClick={() => {
-            submitConfig('quota').then();
-          }}>保存额度设置</Form.Button>
+          <Form.Button
+            onClick={() => {
+              submitConfig('quota').then();
+            }}
+          >
+            保存额度设置
+          </Form.Button>
           <Divider />
-          <Header as="h3">
-            倍率设置
-          </Header>
-          <Form.Group widths="equal">
+          <Header as='h3'>倍率设置</Header>
+          <Form.Group widths='equal'>
             <Form.TextArea
-              label="模型固定价格(一次调用消耗多少刀,优先级大于模型倍率)"
-              name="ModelPrice"
+              label='模型固定价格(一次调用消耗多少刀,优先级大于模型倍率)'
+              name='ModelPrice'
               onChange={handleInputChange}
               style={{ minHeight: 250, fontFamily: 'JetBrains Mono, Consolas' }}
-              autoComplete="new-password"
+              autoComplete='new-password'
               value={inputs.ModelPrice}
               placeholder='为一个 JSON 文本,键为模型名称,值为一次调用消耗多少刀,比如 "gpt-4-gizmo-*": 0.1,一次消耗0.1刀'
             />
           </Form.Group>
-          <Form.Group widths="equal">
+          <Form.Group widths='equal'>
             <Form.TextArea
-              label="模型倍率"
-              name="ModelRatio"
+              label='模型倍率'
+              name='ModelRatio'
               onChange={handleInputChange}
               style={{ minHeight: 250, fontFamily: 'JetBrains Mono, Consolas' }}
-              autoComplete="new-password"
+              autoComplete='new-password'
               value={inputs.ModelRatio}
-              placeholder="为一个 JSON 文本,键为模型名称,值为倍率"
+              placeholder='为一个 JSON 文本,键为模型名称,值为倍率'
             />
           </Form.Group>
-          <Form.Group widths="equal">
+          <Form.Group widths='equal'>
             <Form.TextArea
-              label="分组倍率"
-              name="GroupRatio"
+              label='分组倍率'
+              name='GroupRatio'
               onChange={handleInputChange}
               style={{ minHeight: 250, fontFamily: 'JetBrains Mono, Consolas' }}
-              autoComplete="new-password"
+              autoComplete='new-password'
               value={inputs.GroupRatio}
-              placeholder="为一个 JSON 文本,键为分组名称,值为倍率"
+              placeholder='为一个 JSON 文本,键为分组名称,值为倍率'
             />
           </Form.Group>
-          <Form.Button onClick={() => {
-            submitConfig('ratio').then();
-          }}>保存倍率设置</Form.Button>
+          <Form.Button
+            onClick={() => {
+              submitConfig('ratio').then();
+            }}
+          >
+            保存倍率设置
+          </Form.Button>
         </Form>
       </Grid.Column>
     </Grid>
-  )
-    ;
+  );
 };
 
 export default OperationSetting;

+ 68 - 31
web/src/components/OtherSetting.js

@@ -10,21 +10,20 @@ const OtherSetting = () => {
     Logo: '',
     Footer: '',
     About: '',
-    HomePageContent: ''
+    HomePageContent: '',
   });
   let [loading, setLoading] = useState(false);
   const [showUpdateModal, setShowUpdateModal] = useState(false);
   const [updateData, setUpdateData] = useState({
     tag_name: '',
-    content: ''
+    content: '',
   });
 
-
   const updateOption = async (key, value) => {
     setLoading(true);
     const res = await API.put('/api/option/', {
       key,
-      value
+      value,
     });
     const { success, message } = res.data;
     if (success) {
@@ -41,7 +40,7 @@ const OtherSetting = () => {
     Logo: false,
     HomePageContent: false,
     About: false,
-    Footer: false
+    Footer: false,
   });
   const handleInputChange = async (value, e) => {
     const name = e.target.id;
@@ -68,14 +67,20 @@ const OtherSetting = () => {
   //  个性化设置 - SystemName
   const submitSystemName = async () => {
     try {
-      setLoadingInput((loadingInput) => ({ ...loadingInput, SystemName: true }));
+      setLoadingInput((loadingInput) => ({
+        ...loadingInput,
+        SystemName: true,
+      }));
       await updateOption('SystemName', inputs.SystemName);
       showSuccess('系统名称已更新');
     } catch (error) {
       console.error('系统名称更新失败', error);
       showError('系统名称更新失败');
     } finally {
-      setLoadingInput((loadingInput) => ({ ...loadingInput, SystemName: false }));
+      setLoadingInput((loadingInput) => ({
+        ...loadingInput,
+        SystemName: false,
+      }));
     }
   };
 
@@ -95,14 +100,20 @@ const OtherSetting = () => {
   // 个性化设置 - 首页内容
   const submitOption = async (key) => {
     try {
-      setLoadingInput((loadingInput) => ({ ...loadingInput, HomePageContent: true }));
+      setLoadingInput((loadingInput) => ({
+        ...loadingInput,
+        HomePageContent: true,
+      }));
       await updateOption(key, inputs[key]);
       showSuccess('首页内容已更新');
     } catch (error) {
       console.error('首页内容更新失败', error);
       showError('首页内容更新失败');
     } finally {
-      setLoadingInput((loadingInput) => ({ ...loadingInput, HomePageContent: false }));
+      setLoadingInput((loadingInput) => ({
+        ...loadingInput,
+        HomePageContent: false,
+      }));
     }
   };
   // 个性化设置 - 关于
@@ -132,15 +143,13 @@ const OtherSetting = () => {
     }
   };
 
-
   const openGitHubRelease = () => {
-    window.location =
-      'https://github.com/songquanpeng/one-api/releases/latest';
+    window.location = 'https://github.com/songquanpeng/one-api/releases/latest';
   };
 
   const checkUpdate = async () => {
     const res = await API.get(
-      'https://api.github.com/repos/songquanpeng/one-api/releases/latest'
+      'https://api.github.com/repos/songquanpeng/one-api/releases/latest',
     );
     const { tag_name, body } = res.data;
     if (tag_name === process.env.REACT_APP_VERSION) {
@@ -148,7 +157,7 @@ const OtherSetting = () => {
     } else {
       setUpdateData({
         tag_name: tag_name,
-        content: marked.parse(body)
+        content: marked.parse(body),
       });
       setShowUpdateModal(true);
     }
@@ -175,13 +184,15 @@ const OtherSetting = () => {
     getOptions();
   }, []);
 
-
   return (
     <Row>
       <Col span={24}>
         {/* 通用设置 */}
-        <Form values={inputs} getFormApi={formAPI => formAPISettingGeneral.current = formAPI}
-              style={{ marginBottom: 15 }}>
+        <Form
+          values={inputs}
+          getFormApi={(formAPI) => (formAPISettingGeneral.current = formAPI)}
+          style={{ marginBottom: 15 }}
+        >
           <Form.Section text={'通用设置'}>
             <Form.TextArea
               label={'公告'}
@@ -191,12 +202,17 @@ const OtherSetting = () => {
               style={{ fontFamily: 'JetBrains Mono, Consolas' }}
               autosize={{ minRows: 6, maxRows: 12 }}
             />
-            <Button onClick={submitNotice} loading={loadingInput['Notice']}>设置公告</Button>
+            <Button onClick={submitNotice} loading={loadingInput['Notice']}>
+              设置公告
+            </Button>
           </Form.Section>
         </Form>
         {/* 个性化设置 */}
-        <Form values={inputs} getFormApi={formAPI => formAPIPersonalization.current = formAPI}
-              style={{ marginBottom: 15 }}>
+        <Form
+          values={inputs}
+          getFormApi={(formAPI) => (formAPIPersonalization.current = formAPI)}
+          style={{ marginBottom: 15 }}
+        >
           <Form.Section text={'个性化设置'}>
             <Form.Input
               label={'系统名称'}
@@ -204,48 +220,69 @@ const OtherSetting = () => {
               field={'SystemName'}
               onChange={handleInputChange}
             />
-            <Button onClick={submitSystemName} loading={loadingInput['SystemName']}>设置系统名称</Button>
+            <Button
+              onClick={submitSystemName}
+              loading={loadingInput['SystemName']}
+            >
+              设置系统名称
+            </Button>
             <Form.Input
               label={'Logo 图片地址'}
               placeholder={'在此输入 Logo 图片地址'}
               field={'Logo'}
               onChange={handleInputChange}
             />
-            <Button onClick={submitLogo} loading={loadingInput['Logo']}>设置 Logo</Button>
+            <Button onClick={submitLogo} loading={loadingInput['Logo']}>
+              设置 Logo
+            </Button>
             <Form.TextArea
               label={'首页内容'}
-              placeholder={'在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。'}
+              placeholder={
+                '在此输入首页内容,支持 Markdown & HTML 代码,设置后首页的状态信息将不再显示。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为首页。'
+              }
               field={'HomePageContent'}
               onChange={handleInputChange}
               style={{ fontFamily: 'JetBrains Mono, Consolas' }}
               autosize={{ minRows: 6, maxRows: 12 }}
             />
-            <Button onClick={() => submitOption('HomePageContent')}
-                    loading={loadingInput['HomePageContent']}>设置首页内容</Button>
+            <Button
+              onClick={() => submitOption('HomePageContent')}
+              loading={loadingInput['HomePageContent']}
+            >
+              设置首页内容
+            </Button>
             <Form.TextArea
               label={'关于'}
-              placeholder={'在此输入新的关于内容,支持 Markdown & HTML 代码。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为关于页面。'}
+              placeholder={
+                '在此输入新的关于内容,支持 Markdown & HTML 代码。如果输入的是一个链接,则会使用该链接作为 iframe 的 src 属性,这允许你设置任意网页作为关于页面。'
+              }
               field={'About'}
               onChange={handleInputChange}
               style={{ fontFamily: 'JetBrains Mono, Consolas' }}
               autosize={{ minRows: 6, maxRows: 12 }}
             />
-            <Button onClick={submitAbout} loading={loadingInput['About']}>设置关于</Button>
+            <Button onClick={submitAbout} loading={loadingInput['About']}>
+              设置关于
+            </Button>
             {/*  */}
             <Banner
               fullMode={false}
-              type="info"
-              description="移除 One API 的版权标识必须首先获得授权,项目维护需要花费大量精力,如果本项目对你有意义,请主动支持本项目。"
+              type='info'
+              description='移除 One API 的版权标识必须首先获得授权,项目维护需要花费大量精力,如果本项目对你有意义,请主动支持本项目。'
               closeIcon={null}
               style={{ marginTop: 15 }}
             />
             <Form.Input
               label={'页脚'}
-              placeholder={'在此输入新的页脚,留空则使用默认页脚,支持 HTML 代码'}
+              placeholder={
+                '在此输入新的页脚,留空则使用默认页脚,支持 HTML 代码'
+              }
               field={'Footer'}
               onChange={handleInputChange}
             />
-            <Button onClick={submitFooter} loading={loadingInput['Footer']}>设置页脚</Button>
+            <Button onClick={submitFooter} loading={loadingInput['Footer']}>
+              设置页脚
+            </Button>
           </Form.Section>
         </Form>
       </Col>

+ 17 - 17
web/src/components/PasswordResetConfirm.js

@@ -6,7 +6,7 @@ import { useSearchParams } from 'react-router-dom';
 const PasswordResetConfirm = () => {
   const [inputs, setInputs] = useState({
     email: '',
-    token: ''
+    token: '',
   });
   const { email, token } = inputs;
 
@@ -23,7 +23,7 @@ const PasswordResetConfirm = () => {
     let email = searchParams.get('email');
     setInputs({
       token,
-      email
+      email,
     });
   }, []);
 
@@ -46,7 +46,7 @@ const PasswordResetConfirm = () => {
     setLoading(true);
     const res = await API.post(`/api/user/reset`, {
       email,
-      token
+      token,
     });
     const { success, message } = res.data;
     if (success) {
@@ -61,29 +61,29 @@ const PasswordResetConfirm = () => {
   }
 
   return (
-    <Grid textAlign="center" style={{ marginTop: '48px' }}>
+    <Grid textAlign='center' style={{ marginTop: '48px' }}>
       <Grid.Column style={{ maxWidth: 450 }}>
-        <Header as="h2" color="" textAlign="center">
-          <Image src="/logo.png" /> 密码重置确认
+        <Header as='h2' color='' textAlign='center'>
+          <Image src='/logo.png' /> 密码重置确认
         </Header>
-        <Form size="large">
+        <Form size='large'>
           <Segment>
             <Form.Input
               fluid
-              icon="mail"
-              iconPosition="left"
-              placeholder="邮箱地址"
-              name="email"
+              icon='mail'
+              iconPosition='left'
+              placeholder='邮箱地址'
+              name='email'
               value={email}
               readOnly
             />
             {newPassword && (
               <Form.Input
                 fluid
-                icon="lock"
-                iconPosition="left"
-                placeholder="新密码"
-                name="newPassword"
+                icon='lock'
+                iconPosition='left'
+                placeholder='新密码'
+                name='newPassword'
                 value={newPassword}
                 readOnly
                 onClick={(e) => {
@@ -94,9 +94,9 @@ const PasswordResetConfirm = () => {
               />
             )}
             <Button
-              color="green"
+              color='green'
               fluid
-              size="large"
+              size='large'
               onClick={handleSubmit}
               loading={loading}
               disabled={disableButton}

+ 13 - 13
web/src/components/PasswordResetForm.js

@@ -5,7 +5,7 @@ import Turnstile from 'react-turnstile';
 
 const PasswordResetForm = () => {
   const [inputs, setInputs] = useState({
-    email: ''
+    email: '',
   });
   const { email } = inputs;
 
@@ -31,7 +31,7 @@ const PasswordResetForm = () => {
 
   function handleChange(e) {
     const { name, value } = e.target;
-    setInputs(inputs => ({ ...inputs, [name]: value }));
+    setInputs((inputs) => ({ ...inputs, [name]: value }));
   }
 
   async function handleSubmit(e) {
@@ -43,7 +43,7 @@ const PasswordResetForm = () => {
     }
     setLoading(true);
     const res = await API.get(
-      `/api/reset_password?email=${email}&turnstile=${turnstileToken}`
+      `/api/reset_password?email=${email}&turnstile=${turnstileToken}`,
     );
     const { success, message } = res.data;
     if (success) {
@@ -56,19 +56,19 @@ const PasswordResetForm = () => {
   }
 
   return (
-    <Grid textAlign="center" style={{ marginTop: '48px' }}>
+    <Grid textAlign='center' style={{ marginTop: '48px' }}>
       <Grid.Column style={{ maxWidth: 450 }}>
-        <Header as="h2" color="" textAlign="center">
-          <Image src="/logo.png" /> 密码重置
+        <Header as='h2' color='' textAlign='center'>
+          <Image src='/logo.png' /> 密码重置
         </Header>
-        <Form size="large">
+        <Form size='large'>
           <Segment>
             <Form.Input
               fluid
-              icon="mail"
-              iconPosition="left"
-              placeholder="邮箱地址"
-              name="email"
+              icon='mail'
+              iconPosition='left'
+              placeholder='邮箱地址'
+              name='email'
               value={email}
               onChange={handleChange}
             />
@@ -83,9 +83,9 @@ const PasswordResetForm = () => {
               <></>
             )}
             <Button
-              color="green"
+              color='green'
               fluid
-              size="large"
+              size='large'
               onClick={handleSubmit}
               loading={loading}
               disabled={disableButton}

+ 234 - 123
web/src/components/PersonalSetting.js

@@ -1,6 +1,13 @@
 import React, { useContext, useEffect, useState } from 'react';
 import { useNavigate } from 'react-router-dom';
-import { API, copy, isRoot, showError, showInfo, showSuccess } from '../helpers';
+import {
+  API,
+  copy,
+  isRoot,
+  showError,
+  showInfo,
+  showSuccess,
+} from '../helpers';
 import Turnstile from 'react-turnstile';
 import { UserContext } from '../context/User';
 import { onGitHubOAuthClicked } from './utils';
@@ -17,9 +24,14 @@ import {
   Modal,
   Space,
   Tag,
-  Typography
+  Typography,
 } from '@douyinfe/semi-ui';
-import { getQuotaPerUnit, renderQuota, renderQuotaWithPrompt, stringToColor } from '../helpers/render';
+import {
+  getQuotaPerUnit,
+  renderQuota,
+  renderQuotaWithPrompt,
+  stringToColor,
+} from '../helpers/render';
 import TelegramLoginButton from 'react-telegram-login';
 
 const PersonalSetting = () => {
@@ -32,7 +44,7 @@ const PersonalSetting = () => {
     email: '',
     self_account_deletion_confirmation: '',
     set_new_password: '',
-    set_new_password_confirmation: ''
+    set_new_password_confirmation: '',
   });
   const [status, setStatus] = useState({});
   const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
@@ -67,11 +79,9 @@ const PersonalSetting = () => {
         setTurnstileSiteKey(status.turnstile_site_key);
       }
     }
-    getUserData().then(
-      (res) => {
-        console.log(userState);
-      }
-    );
+    getUserData().then((res) => {
+      console.log(userState);
+    });
     loadModels().then();
     getAffLink().then();
     setTransferAmount(getQuotaPerUnit());
@@ -173,7 +183,7 @@ const PersonalSetting = () => {
   const bindWeChat = async () => {
     if (inputs.wechat_verification_code === '') return;
     const res = await API.get(
-      `/api/oauth/wechat/bind?code=${inputs.wechat_verification_code}`
+      `/api/oauth/wechat/bind?code=${inputs.wechat_verification_code}`,
     );
     const { success, message } = res.data;
     if (success) {
@@ -189,12 +199,9 @@ const PersonalSetting = () => {
       showError('两次输入的密码不一致!');
       return;
     }
-    const res = await API.put(
-      `/api/user/self`,
-      {
-        password: inputs.set_new_password
-      }
-    );
+    const res = await API.put(`/api/user/self`, {
+      password: inputs.set_new_password,
+    });
     const { success, message } = res.data;
     if (success) {
       showSuccess('密码修改成功!');
@@ -210,12 +217,9 @@ const PersonalSetting = () => {
       showError('划转金额最低为' + renderQuota(getQuotaPerUnit()));
       return;
     }
-    const res = await API.post(
-      `/api/user/aff_transfer`,
-      {
-        quota: transferAmount
-      }
-    );
+    const res = await API.post(`/api/user/aff_transfer`, {
+      quota: transferAmount,
+    });
     const { success, message } = res.data;
     if (success) {
       showSuccess(message);
@@ -238,7 +242,7 @@ const PersonalSetting = () => {
     }
     setLoading(true);
     const res = await API.get(
-      `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`
+      `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`,
     );
     const { success, message } = res.data;
     if (success) {
@@ -256,7 +260,7 @@ const PersonalSetting = () => {
     }
     setLoading(true);
     const res = await API.get(
-      `/api/oauth/email/bind?email=${inputs.email}&code=${inputs.email_verification_code}`
+      `/api/oauth/email/bind?email=${inputs.email}&code=${inputs.email_verification_code}`,
     );
     const { success, message } = res.data;
     if (success) {
@@ -295,7 +299,7 @@ const PersonalSetting = () => {
       <Layout>
         <Layout.Content>
           <Modal
-            title="请输入要划转的数量"
+            title='请输入要划转的数量'
             visible={openTransfer}
             onOk={transfer}
             onCancel={handleCancel}
@@ -305,13 +309,25 @@ const PersonalSetting = () => {
           >
             <div style={{ marginTop: 20 }}>
               <Typography.Text>{`可用额度${renderQuotaWithPrompt(userState?.user?.aff_quota)}`}</Typography.Text>
-              <Input style={{ marginTop: 5 }} value={userState?.user?.aff_quota} disabled={true}></Input>
+              <Input
+                style={{ marginTop: 5 }}
+                value={userState?.user?.aff_quota}
+                disabled={true}
+              ></Input>
             </div>
             <div style={{ marginTop: 20 }}>
-              <Typography.Text>{`划转额度${renderQuotaWithPrompt(transferAmount)} 最低` + renderQuota(getQuotaPerUnit())}</Typography.Text>
+              <Typography.Text>
+                {`划转额度${renderQuotaWithPrompt(transferAmount)} 最低` +
+                  renderQuota(getQuotaPerUnit())}
+              </Typography.Text>
               <div>
-                <InputNumber min={0} style={{ marginTop: 5 }} value={transferAmount}
-                             onChange={(value) => setTransferAmount(value)} disabled={false}></InputNumber>
+                <InputNumber
+                  min={0}
+                  style={{ marginTop: 5 }}
+                  value={transferAmount}
+                  onChange={(value) => setTransferAmount(value)}
+                  disabled={false}
+                ></InputNumber>
               </div>
             </div>
           </Modal>
@@ -319,27 +335,45 @@ const PersonalSetting = () => {
             <Card
               title={
                 <Card.Meta
-                  avatar={<Avatar size="default" color={stringToColor(getUsername())}
-                                  style={{ marginRight: 4 }}>
-                    {typeof getUsername() === 'string' && getUsername().slice(0, 1)}
-                  </Avatar>}
+                  avatar={
+                    <Avatar
+                      size='default'
+                      color={stringToColor(getUsername())}
+                      style={{ marginRight: 4 }}
+                    >
+                      {typeof getUsername() === 'string' &&
+                        getUsername().slice(0, 1)}
+                    </Avatar>
+                  }
                   title={<Typography.Text>{getUsername()}</Typography.Text>}
-                  description={isRoot() ? <Tag color="red">管理员</Tag> : <Tag color="blue">普通用户</Tag>}
+                  description={
+                    isRoot() ? (
+                      <Tag color='red'>管理员</Tag>
+                    ) : (
+                      <Tag color='blue'>普通用户</Tag>
+                    )
+                  }
                 ></Card.Meta>
               }
               headerExtraContent={
                 <>
-                  <Space vertical align="start">
-                    <Tag color="green">{'ID: ' + userState?.user?.id}</Tag>
-                    <Tag color="blue">{userState?.user?.group}</Tag>
+                  <Space vertical align='start'>
+                    <Tag color='green'>{'ID: ' + userState?.user?.id}</Tag>
+                    <Tag color='blue'>{userState?.user?.group}</Tag>
                   </Space>
                 </>
               }
               footer={
                 <Descriptions row>
-                  <Descriptions.Item itemKey="当前余额">{renderQuota(userState?.user?.quota)}</Descriptions.Item>
-                  <Descriptions.Item itemKey="历史消耗">{renderQuota(userState?.user?.used_quota)}</Descriptions.Item>
-                  <Descriptions.Item itemKey="请求次数">{userState.user?.request_count}</Descriptions.Item>
+                  <Descriptions.Item itemKey='当前余额'>
+                    {renderQuota(userState?.user?.quota)}
+                  </Descriptions.Item>
+                  <Descriptions.Item itemKey='历史消耗'>
+                    {renderQuota(userState?.user?.used_quota)}
+                  </Descriptions.Item>
+                  <Descriptions.Item itemKey='请求次数'>
+                    {userState.user?.request_count}
+                  </Descriptions.Item>
                 </Descriptions>
               }
             >
@@ -347,15 +381,18 @@ const PersonalSetting = () => {
               <div style={{ marginTop: 10 }}>
                 <Space wrap>
                   {models.map((model) => (
-                    <Tag key={model} color="cyan" onClick={() => {
-                      copyText(model);
-                    }}>
+                    <Tag
+                      key={model}
+                      color='cyan'
+                      onClick={() => {
+                        copyText(model);
+                      }}
+                    >
                       {model}
                     </Tag>
                   ))}
                 </Space>
               </div>
-
             </Card>
             <Card
               footer={
@@ -373,18 +410,25 @@ const PersonalSetting = () => {
               <Typography.Title heading={6}>邀请信息</Typography.Title>
               <div style={{ marginTop: 10 }}>
                 <Descriptions row>
-                  <Descriptions.Item itemKey="待使用收益">
-                                        <span style={{ color: 'rgba(var(--semi-red-5), 1)' }}>
-                                            {
-                                              renderQuota(userState?.user?.aff_quota)
-                                            }
-                                        </span>
-                    <Button type={'secondary'} onClick={() => setOpenTransfer(true)} size={'small'}
-                            style={{ marginLeft: 10 }}>划转</Button>
+                  <Descriptions.Item itemKey='待使用收益'>
+                    <span style={{ color: 'rgba(var(--semi-red-5), 1)' }}>
+                      {renderQuota(userState?.user?.aff_quota)}
+                    </span>
+                    <Button
+                      type={'secondary'}
+                      onClick={() => setOpenTransfer(true)}
+                      size={'small'}
+                      style={{ marginLeft: 10 }}
+                    >
+                      划转
+                    </Button>
+                  </Descriptions.Item>
+                  <Descriptions.Item itemKey='总收益'>
+                    {renderQuota(userState?.user?.aff_history_quota)}
+                  </Descriptions.Item>
+                  <Descriptions.Item itemKey='邀请人数'>
+                    {userState?.user?.aff_count}
                   </Descriptions.Item>
-                  <Descriptions.Item
-                    itemKey="总收益">{renderQuota(userState?.user?.aff_history_quota)}</Descriptions.Item>
-                  <Descriptions.Item itemKey="邀请人数">{userState?.user?.aff_count}</Descriptions.Item>
                 </Descriptions>
               </div>
             </Card>
@@ -392,46 +436,71 @@ const PersonalSetting = () => {
               <Typography.Title heading={6}>个人信息</Typography.Title>
               <div style={{ marginTop: 20 }}>
                 <Typography.Text strong>邮箱</Typography.Text>
-                <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+                <div
+                  style={{ display: 'flex', justifyContent: 'space-between' }}
+                >
                   <div>
                     <Input
-                      value={userState.user && userState.user.email !== '' ? userState.user.email : '未绑定'}
+                      value={
+                        userState.user && userState.user.email !== ''
+                          ? userState.user.email
+                          : '未绑定'
+                      }
                       readonly={true}
                     ></Input>
                   </div>
                   <div>
-                    <Button onClick={() => {
-                      setShowEmailBindModal(true);
-                    }}>{
-                      userState.user && userState.user.email !== '' ? '修改绑定' : '绑定邮箱'
-                    }</Button>
+                    <Button
+                      onClick={() => {
+                        setShowEmailBindModal(true);
+                      }}
+                    >
+                      {userState.user && userState.user.email !== ''
+                        ? '修改绑定'
+                        : '绑定邮箱'}
+                    </Button>
                   </div>
                 </div>
               </div>
               <div style={{ marginTop: 10 }}>
                 <Typography.Text strong>微信</Typography.Text>
-                <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+                <div
+                  style={{ display: 'flex', justifyContent: 'space-between' }}
+                >
                   <div>
                     <Input
-                      value={userState.user && userState.user.wechat_id !== '' ? '已绑定' : '未绑定'}
+                      value={
+                        userState.user && userState.user.wechat_id !== ''
+                          ? '已绑定'
+                          : '未绑定'
+                      }
                       readonly={true}
                     ></Input>
                   </div>
                   <div>
-                    <Button disabled={(userState.user && userState.user.wechat_id !== '') || !status.wechat_login}>
-                      {
-                        status.wechat_login ? '绑定' : '未启用'
+                    <Button
+                      disabled={
+                        (userState.user && userState.user.wechat_id !== '') ||
+                        !status.wechat_login
                       }
+                    >
+                      {status.wechat_login ? '绑定' : '未启用'}
                     </Button>
                   </div>
                 </div>
               </div>
               <div style={{ marginTop: 10 }}>
                 <Typography.Text strong>GitHub</Typography.Text>
-                <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+                <div
+                  style={{ display: 'flex', justifyContent: 'space-between' }}
+                >
                   <div>
                     <Input
-                      value={userState.user && userState.user.github_id !== '' ? userState.user.github_id : '未绑定'}
+                      value={
+                        userState.user && userState.user.github_id !== ''
+                          ? userState.user.github_id
+                          : '未绑定'
+                      }
                       readonly={true}
                     ></Input>
                   </div>
@@ -440,11 +509,12 @@ const PersonalSetting = () => {
                       onClick={() => {
                         onGitHubOAuthClicked(status.github_client_id);
                       }}
-                      disabled={(userState.user && userState.user.github_id !== '') || !status.github_oauth}
-                    >
-                      {
-                        status.github_oauth ? '绑定' : '未启用'
+                      disabled={
+                        (userState.user && userState.user.github_id !== '') ||
+                        !status.github_oauth
                       }
+                    >
+                      {status.github_oauth ? '绑定' : '未启用'}
                     </Button>
                   </div>
                 </div>
@@ -452,33 +522,56 @@ const PersonalSetting = () => {
 
               <div style={{ marginTop: 10 }}>
                 <Typography.Text strong>Telegram</Typography.Text>
-                <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+                <div
+                  style={{ display: 'flex', justifyContent: 'space-between' }}
+                >
                   <div>
                     <Input
-                      value={userState.user && userState.user.telegram_id !== '' ? userState.user.telegram_id : '未绑定'}
+                      value={
+                        userState.user && userState.user.telegram_id !== ''
+                          ? userState.user.telegram_id
+                          : '未绑定'
+                      }
                       readonly={true}
                     ></Input>
                   </div>
                   <div>
-                    {status.telegram_oauth ?
-                      userState.user.telegram_id !== '' ? <Button disabled={true}>已绑定</Button>
-                        : <TelegramLoginButton dataAuthUrl="/api/oauth/telegram/bind"
-                                               botName={status.telegram_bot_name} />
-                      : <Button disabled={true}>未启用</Button>
-                    }
+                    {status.telegram_oauth ? (
+                      userState.user.telegram_id !== '' ? (
+                        <Button disabled={true}>已绑定</Button>
+                      ) : (
+                        <TelegramLoginButton
+                          dataAuthUrl='/api/oauth/telegram/bind'
+                          botName={status.telegram_bot_name}
+                        />
+                      )
+                    ) : (
+                      <Button disabled={true}>未启用</Button>
+                    )}
                   </div>
                 </div>
               </div>
 
               <div style={{ marginTop: 10 }}>
                 <Space>
-                  <Button onClick={generateAccessToken}>生成系统访问令牌</Button>
-                  <Button onClick={() => {
-                    setShowChangePasswordModal(true);
-                  }}>修改密码</Button>
-                  <Button type={'danger'} onClick={() => {
-                    setShowAccountDeleteModal(true);
-                  }}>删除个人账户</Button>
+                  <Button onClick={generateAccessToken}>
+                    生成系统访问令牌
+                  </Button>
+                  <Button
+                    onClick={() => {
+                      setShowChangePasswordModal(true);
+                    }}
+                  >
+                    修改密码
+                  </Button>
+                  <Button
+                    type={'danger'}
+                    onClick={() => {
+                      setShowAccountDeleteModal(true);
+                    }}
+                  >
+                    删除个人账户
+                  </Button>
                 </Space>
 
                 {systemToken && (
@@ -489,17 +582,15 @@ const PersonalSetting = () => {
                     style={{ marginTop: '10px' }}
                   />
                 )}
-                {
-                  status.wechat_login && (
-                    <Button
-                      onClick={() => {
-                        setShowWeChatBindModal(true);
-                      }}
-                    >
-                      绑定微信账号
-                    </Button>
-                  )
-                }
+                {status.wechat_login && (
+                  <Button
+                    onClick={() => {
+                      setShowWeChatBindModal(true);
+                    }}
+                  >
+                    绑定微信账号
+                  </Button>
+                )}
                 <Modal
                   onCancel={() => setShowWeChatBindModal(false)}
                   // onOpen={() => setShowWeChatBindModal(true)}
@@ -513,12 +604,14 @@ const PersonalSetting = () => {
                     </p>
                   </div>
                   <Input
-                    placeholder="验证码"
-                    name="wechat_verification_code"
+                    placeholder='验证码'
+                    name='wechat_verification_code'
                     value={inputs.wechat_verification_code}
-                    onChange={(v) => handleInputChange('wechat_verification_code', v)}
+                    onChange={(v) =>
+                      handleInputChange('wechat_verification_code', v)
+                    }
                   />
-                  <Button color="" fluid size="large" onClick={bindWeChat}>
+                  <Button color='' fluid size='large' onClick={bindWeChat}>
                     绑定
                   </Button>
                 </Modal>
@@ -534,26 +627,36 @@ const PersonalSetting = () => {
               maskClosable={false}
             >
               <Typography.Title heading={6}>绑定邮箱地址</Typography.Title>
-              <div style={{ marginTop: 20, display: 'flex', justifyContent: 'space-between' }}>
+              <div
+                style={{
+                  marginTop: 20,
+                  display: 'flex',
+                  justifyContent: 'space-between',
+                }}
+              >
                 <Input
                   fluid
-                  placeholder="输入邮箱地址"
+                  placeholder='输入邮箱地址'
                   onChange={(value) => handleInputChange('email', value)}
-                  name="email"
-                  type="email"
+                  name='email'
+                  type='email'
                 />
-                <Button onClick={sendVerificationCode}
-                        disabled={disableButton || loading}>
+                <Button
+                  onClick={sendVerificationCode}
+                  disabled={disableButton || loading}
+                >
                   {disableButton ? `重新发送(${countdown})` : '获取验证码'}
                 </Button>
               </div>
               <div style={{ marginTop: 10 }}>
                 <Input
                   fluid
-                  placeholder="验证码"
-                  name="email_verification_code"
+                  placeholder='验证码'
+                  name='email_verification_code'
                   value={inputs.email_verification_code}
-                  onChange={(value) => handleInputChange('email_verification_code', value)}
+                  onChange={(value) =>
+                    handleInputChange('email_verification_code', value)
+                  }
                 />
               </div>
               {turnstileEnabled ? (
@@ -576,17 +679,22 @@ const PersonalSetting = () => {
             >
               <div style={{ marginTop: 20 }}>
                 <Banner
-                  type="danger"
-                  description="您正在删除自己的帐户,将清空所有数据且不可恢复"
+                  type='danger'
+                  description='您正在删除自己的帐户,将清空所有数据且不可恢复'
                   closeIcon={null}
                 />
               </div>
               <div style={{ marginTop: 20 }}>
                 <Input
                   placeholder={`输入你的账户名 ${userState?.user?.username} 以确认删除`}
-                  name="self_account_deletion_confirmation"
+                  name='self_account_deletion_confirmation'
                   value={inputs.self_account_deletion_confirmation}
-                  onChange={(value) => handleInputChange('self_account_deletion_confirmation', value)}
+                  onChange={(value) =>
+                    handleInputChange(
+                      'self_account_deletion_confirmation',
+                      value,
+                    )
+                  }
                 />
                 {turnstileEnabled ? (
                   <Turnstile
@@ -609,17 +717,21 @@ const PersonalSetting = () => {
             >
               <div style={{ marginTop: 20 }}>
                 <Input
-                  name="set_new_password"
-                  placeholder="新密码"
+                  name='set_new_password'
+                  placeholder='新密码'
                   value={inputs.set_new_password}
-                  onChange={(value) => handleInputChange('set_new_password', value)}
+                  onChange={(value) =>
+                    handleInputChange('set_new_password', value)
+                  }
                 />
                 <Input
                   style={{ marginTop: 20 }}
-                  name="set_new_password_confirmation"
-                  placeholder="确认新密码"
+                  name='set_new_password_confirmation'
+                  placeholder='确认新密码'
                   value={inputs.set_new_password_confirmation}
-                  onChange={(value) => handleInputChange('set_new_password_confirmation', value)}
+                  onChange={(value) =>
+                    handleInputChange('set_new_password_confirmation', value)
+                  }
                 />
                 {turnstileEnabled ? (
                   <Turnstile
@@ -634,7 +746,6 @@ const PersonalSetting = () => {
               </div>
             </Modal>
           </div>
-
         </Layout.Content>
       </Layout>
     </div>

+ 2 - 3
web/src/components/PrivateRoute.js

@@ -2,12 +2,11 @@ import { Navigate } from 'react-router-dom';
 
 import { history } from '../helpers';
 
-
 function PrivateRoute({ children }) {
   if (!localStorage.getItem('user')) {
-    return <Navigate to="/login" state={{ from: history.location }} />;
+    return <Navigate to='/login' state={{ from: history.location }} />;
   }
   return children;
 }
 
-export { PrivateRoute };
+export { PrivateRoute };

+ 177 - 132
web/src/components/RedemptionsTable.js

@@ -1,29 +1,58 @@
 import React, { useEffect, useState } from 'react';
-import { API, copy, showError, showSuccess, timestamp2string } from '../helpers';
+import {
+  API,
+  copy,
+  showError,
+  showSuccess,
+  timestamp2string,
+} from '../helpers';
 
 import { ITEMS_PER_PAGE } from '../constants';
 import { renderQuota } from '../helpers/render';
-import { Button, Form, Modal, Popconfirm, Popover, Table, Tag } from '@douyinfe/semi-ui';
+import {
+  Button,
+  Form,
+  Modal,
+  Popconfirm,
+  Popover,
+  Table,
+  Tag,
+} from '@douyinfe/semi-ui';
 import EditRedemption from '../pages/Redemption/EditRedemption';
 
 function renderTimestamp(timestamp) {
-  return (
-    <>
-      {timestamp2string(timestamp)}
-    </>
-  );
+  return <>{timestamp2string(timestamp)}</>;
 }
 
 function renderStatus(status) {
   switch (status) {
     case 1:
-      return <Tag color="green" size="large">未使用</Tag>;
+      return (
+        <Tag color='green' size='large'>
+          未使用
+        </Tag>
+      );
     case 2:
-      return <Tag color="red" size="large"> 已禁用 </Tag>;
+      return (
+        <Tag color='red' size='large'>
+          {' '}
+          已禁用{' '}
+        </Tag>
+      );
     case 3:
-      return <Tag color="grey" size="large"> 已使用 </Tag>;
+      return (
+        <Tag color='grey' size='large'>
+          {' '}
+          已使用{' '}
+        </Tag>
+      );
     default:
-      return <Tag color="black" size="large"> 未知状态 </Tag>;
+      return (
+        <Tag color='black' size='large'>
+          {' '}
+          未知状态{' '}
+        </Tag>
+      );
   }
 }
 
@@ -31,121 +60,115 @@ const RedemptionsTable = () => {
   const columns = [
     {
       title: 'ID',
-      dataIndex: 'id'
+      dataIndex: 'id',
     },
     {
       title: '名称',
-      dataIndex: 'name'
+      dataIndex: 'name',
     },
     {
       title: '状态',
       dataIndex: 'status',
       key: 'status',
       render: (text, record, index) => {
-        return (
-          <div>
-            {renderStatus(text)}
-          </div>
-        );
-      }
+        return <div>{renderStatus(text)}</div>;
+      },
     },
     {
       title: '额度',
       dataIndex: 'quota',
       render: (text, record, index) => {
-        return (
-          <div>
-            {renderQuota(parseInt(text))}
-          </div>
-        );
-      }
+        return <div>{renderQuota(parseInt(text))}</div>;
+      },
     },
     {
       title: '创建时间',
       dataIndex: 'created_time',
       render: (text, record, index) => {
-        return (
-          <div>
-            {renderTimestamp(text)}
-          </div>
-        );
-      }
+        return <div>{renderTimestamp(text)}</div>;
+      },
     },
     {
       title: '兑换人ID',
       dataIndex: 'used_user_id',
       render: (text, record, index) => {
-        return (
-          <div>
-            {text === 0 ? '无' : text}
-          </div>
-        );
-      }
+        return <div>{text === 0 ? '无' : text}</div>;
+      },
     },
     {
       title: '',
       dataIndex: 'operate',
       render: (text, record, index) => (
         <div>
-          <Popover
-            content={
-              record.key
-            }
-            style={{ padding: 20 }}
-            position="top"
-          >
-            <Button theme="light" type="tertiary" style={{ marginRight: 1 }}>查看</Button>
+          <Popover content={record.key} style={{ padding: 20 }} position='top'>
+            <Button theme='light' type='tertiary' style={{ marginRight: 1 }}>
+              查看
+            </Button>
           </Popover>
-          <Button theme="light" type="secondary" style={{ marginRight: 1 }}
-                  onClick={async (text) => {
-                    await copyText(record.key);
-                  }}
-          >复制</Button>
+          <Button
+            theme='light'
+            type='secondary'
+            style={{ marginRight: 1 }}
+            onClick={async (text) => {
+              await copyText(record.key);
+            }}
+          >
+            复制
+          </Button>
           <Popconfirm
-            title="确定是否要删除此兑换码?"
-            content="此修改将不可逆"
+            title='确定是否要删除此兑换码?'
+            content='此修改将不可逆'
             okType={'danger'}
             position={'left'}
             onConfirm={() => {
-              manageRedemption(record.id, 'delete', record).then(
-                () => {
-                  removeRecord(record.key);
-                }
-              );
+              manageRedemption(record.id, 'delete', record).then(() => {
+                removeRecord(record.key);
+              });
             }}
           >
-            <Button theme="light" type="danger" style={{ marginRight: 1 }}>删除</Button>
+            <Button theme='light' type='danger' style={{ marginRight: 1 }}>
+              删除
+            </Button>
           </Popconfirm>
-          {
-            record.status === 1 ?
-              <Button theme="light" type="warning" style={{ marginRight: 1 }} onClick={
-                async () => {
-                  manageRedemption(
-                    record.id,
-                    'disable',
-                    record
-                  );
-                }
-              }>禁用</Button> :
-              <Button theme="light" type="secondary" style={{ marginRight: 1 }} onClick={
-                async () => {
-                  manageRedemption(
-                    record.id,
-                    'enable',
-                    record
-                  );
-                }
-              } disabled={record.status === 3}>启用</Button>
-          }
-          <Button theme="light" type="tertiary" style={{ marginRight: 1 }} onClick={
-            () => {
+          {record.status === 1 ? (
+            <Button
+              theme='light'
+              type='warning'
+              style={{ marginRight: 1 }}
+              onClick={async () => {
+                manageRedemption(record.id, 'disable', record);
+              }}
+            >
+              禁用
+            </Button>
+          ) : (
+            <Button
+              theme='light'
+              type='secondary'
+              style={{ marginRight: 1 }}
+              onClick={async () => {
+                manageRedemption(record.id, 'enable', record);
+              }}
+              disabled={record.status === 3}
+            >
+              启用
+            </Button>
+          )}
+          <Button
+            theme='light'
+            type='tertiary'
+            style={{ marginRight: 1 }}
+            onClick={() => {
               setEditingRedemption(record);
               setShowEdit(true);
-            }
-          } disabled={record.status !== 1}>编辑</Button>
+            }}
+            disabled={record.status !== 1}
+          >
+            编辑
+          </Button>
         </div>
-      )
-    }
+      ),
+    },
   ];
 
   const [redemptions, setRedemptions] = useState([]);
@@ -156,7 +179,7 @@ const RedemptionsTable = () => {
   const [tokenCount, setTokenCount] = useState(ITEMS_PER_PAGE);
   const [selectedKeys, setSelectedKeys] = useState([]);
   const [editingRedemption, setEditingRedemption] = useState({
-    id: undefined
+    id: undefined,
   });
   const [showEdit, setShowEdit] = useState(false);
 
@@ -178,7 +201,7 @@ const RedemptionsTable = () => {
     // }
     // data.key = '' + data.id
     setRedemptions(redeptions);
-    if (redeptions.length >= (activePage) * ITEMS_PER_PAGE) {
+    if (redeptions.length >= activePage * ITEMS_PER_PAGE) {
       setTokenCount(redeptions.length + 1);
     } else {
       setTokenCount(redeptions.length);
@@ -202,10 +225,10 @@ const RedemptionsTable = () => {
     setLoading(false);
   };
 
-  const removeRecord = key => {
+  const removeRecord = (key) => {
     let newDataSource = [...redemptions];
     if (key != null) {
-      let idx = newDataSource.findIndex(data => data.key === key);
+      let idx = newDataSource.findIndex((data) => data.key === key);
 
       if (idx > -1) {
         newDataSource.splice(idx, 1);
@@ -268,7 +291,6 @@ const RedemptionsTable = () => {
       let newRedemptions = [...redemptions];
       // let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
       if (action === 'delete') {
-
       } else {
         record.status = redemption.status;
       }
@@ -286,7 +308,9 @@ const RedemptionsTable = () => {
       return;
     }
     setSearching(true);
-    const res = await API.get(`/api/redemption/search?keyword=${searchKeyword}`);
+    const res = await API.get(
+      `/api/redemption/search?keyword=${searchKeyword}`,
+    );
     const { success, message, data } = res.data;
     if (success) {
       setRedemptions(data);
@@ -315,32 +339,32 @@ const RedemptionsTable = () => {
     setLoading(false);
   };
 
-  const handlePageChange = page => {
+  const handlePageChange = (page) => {
     setActivePage(page);
     if (page === Math.ceil(redemptions.length / ITEMS_PER_PAGE) + 1) {
       // In this case we have to load more data and then append them.
-      loadRedemptions(page - 1).then(r => {
-      });
+      loadRedemptions(page - 1).then((r) => {});
     }
   };
 
-  let pageData = redemptions.slice((activePage - 1) * ITEMS_PER_PAGE, activePage * ITEMS_PER_PAGE);
+  let pageData = redemptions.slice(
+    (activePage - 1) * ITEMS_PER_PAGE,
+    activePage * ITEMS_PER_PAGE,
+  );
   const rowSelection = {
-    onSelect: (record, selected) => {
-    },
-    onSelectAll: (selected, selectedRows) => {
-    },
+    onSelect: (record, selected) => {},
+    onSelectAll: (selected, selectedRows) => {},
     onChange: (selectedRowKeys, selectedRows) => {
       setSelectedKeys(selectedRows);
-    }
+    },
   };
 
   const handleRow = (record, index) => {
     if (record.status !== 1) {
       return {
         style: {
-          background: 'var(--semi-color-disabled-border)'
-        }
+          background: 'var(--semi-color-disabled-border)',
+        },
       };
     } else {
       return {};
@@ -349,45 +373,64 @@ const RedemptionsTable = () => {
 
   return (
     <>
-      <EditRedemption refresh={refresh} editingRedemption={editingRedemption} visiable={showEdit}
-                      handleClose={closeEdit}></EditRedemption>
+      <EditRedemption
+        refresh={refresh}
+        editingRedemption={editingRedemption}
+        visiable={showEdit}
+        handleClose={closeEdit}
+      ></EditRedemption>
       <Form onSubmit={searchRedemptions}>
         <Form.Input
-          label="搜索关键字"
-          field="keyword"
-          icon="search"
-          iconPosition="left"
-          placeholder="关键字(id或者名称)"
+          label='搜索关键字'
+          field='keyword'
+          icon='search'
+          iconPosition='left'
+          placeholder='关键字(id或者名称)'
           value={searchKeyword}
           loading={searching}
           onChange={handleKeywordChange}
         />
       </Form>
 
-      <Table style={{ marginTop: 20 }} columns={columns} dataSource={pageData} pagination={{
-        currentPage: activePage,
-        pageSize: ITEMS_PER_PAGE,
-        total: tokenCount,
-        // showSizeChanger: true,
-        // pageSizeOptions: [10, 20, 50, 100],
-        formatPageText: (page) => `第 ${page.currentStart} - ${page.currentEnd} 条,共 ${redemptions.length} 条`,
-        // onPageSizeChange: (size) => {
-        //   setPageSize(size);
-        //   setActivePage(1);
-        // },
-        onPageChange: handlePageChange
-      }} loading={loading} rowSelection={rowSelection} onRow={handleRow}>
-      </Table>
-      <Button theme="light" type="primary" style={{ marginRight: 8 }} onClick={
-        () => {
+      <Table
+        style={{ marginTop: 20 }}
+        columns={columns}
+        dataSource={pageData}
+        pagination={{
+          currentPage: activePage,
+          pageSize: ITEMS_PER_PAGE,
+          total: tokenCount,
+          // showSizeChanger: true,
+          // pageSizeOptions: [10, 20, 50, 100],
+          formatPageText: (page) =>
+            `第 ${page.currentStart} - ${page.currentEnd} 条,共 ${redemptions.length} 条`,
+          // onPageSizeChange: (size) => {
+          //   setPageSize(size);
+          //   setActivePage(1);
+          // },
+          onPageChange: handlePageChange,
+        }}
+        loading={loading}
+        rowSelection={rowSelection}
+        onRow={handleRow}
+      ></Table>
+      <Button
+        theme='light'
+        type='primary'
+        style={{ marginRight: 8 }}
+        onClick={() => {
           setEditingRedemption({
-            id: undefined
+            id: undefined,
           });
           setShowEdit(true);
-        }
-      }>添加兑换码</Button>
-      <Button label="复制所选兑换码" type="warning" onClick={
-        async () => {
+        }}
+      >
+        添加兑换码
+      </Button>
+      <Button
+        label='复制所选兑换码'
+        type='warning'
+        onClick={async () => {
           if (selectedKeys.length === 0) {
             showError('请至少选择一个兑换码!');
             return;
@@ -397,8 +440,10 @@ const RedemptionsTable = () => {
             keys += selectedKeys[i].name + '    ' + selectedKeys[i].key + '\n';
           }
           await copyText(keys);
-        }
-      }>复制所选兑换码到剪贴板</Button>
+        }}
+      >
+        复制所选兑换码到剪贴板
+      </Button>
     </>
   );
 };

+ 41 - 33
web/src/components/RegisterForm.js

@@ -1,5 +1,13 @@
 import React, { useEffect, useState } from 'react';
-import { Button, Form, Grid, Header, Image, Message, Segment } from 'semantic-ui-react';
+import {
+  Button,
+  Form,
+  Grid,
+  Header,
+  Image,
+  Message,
+  Segment,
+} from 'semantic-ui-react';
 import { Link, useNavigate } from 'react-router-dom';
 import { API, getLogo, showError, showInfo, showSuccess } from '../helpers';
 import Turnstile from 'react-turnstile';
@@ -10,7 +18,7 @@ const RegisterForm = () => {
     password: '',
     password2: '',
     email: '',
-    verification_code: ''
+    verification_code: '',
   });
   const { username, password, password2 } = inputs;
   const [showEmailVerification, setShowEmailVerification] = useState(false);
@@ -65,7 +73,7 @@ const RegisterForm = () => {
       inputs.aff_code = affCode;
       const res = await API.post(
         `/api/user/register?turnstile=${turnstileToken}`,
-        inputs
+        inputs,
       );
       const { success, message } = res.data;
       if (success) {
@@ -86,7 +94,7 @@ const RegisterForm = () => {
     }
     setLoading(true);
     const res = await API.get(
-      `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`
+      `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`,
     );
     const { success, message } = res.data;
     if (success) {
@@ -98,49 +106,49 @@ const RegisterForm = () => {
   };
 
   return (
-    <Grid textAlign="center" style={{ marginTop: '48px' }}>
+    <Grid textAlign='center' style={{ marginTop: '48px' }}>
       <Grid.Column style={{ maxWidth: 450 }}>
-        <Header as="h2" color="" textAlign="center">
+        <Header as='h2' color='' textAlign='center'>
           <Image src={logo} /> 新用户注册
         </Header>
-        <Form size="large">
+        <Form size='large'>
           <Segment>
             <Form.Input
               fluid
-              icon="user"
-              iconPosition="left"
-              placeholder="输入用户名,最长 12 位"
+              icon='user'
+              iconPosition='left'
+              placeholder='输入用户名,最长 12 位'
               onChange={handleChange}
-              name="username"
+              name='username'
             />
             <Form.Input
               fluid
-              icon="lock"
-              iconPosition="left"
-              placeholder="输入密码,最短 8 位,最长 20 位"
+              icon='lock'
+              iconPosition='left'
+              placeholder='输入密码,最短 8 位,最长 20 位'
               onChange={handleChange}
-              name="password"
-              type="password"
+              name='password'
+              type='password'
             />
             <Form.Input
               fluid
-              icon="lock"
-              iconPosition="left"
-              placeholder="输入密码,最短 8 位,最长 20 位"
+              icon='lock'
+              iconPosition='left'
+              placeholder='输入密码,最短 8 位,最长 20 位'
               onChange={handleChange}
-              name="password2"
-              type="password"
+              name='password2'
+              type='password'
             />
             {showEmailVerification ? (
               <>
                 <Form.Input
                   fluid
-                  icon="mail"
-                  iconPosition="left"
-                  placeholder="输入邮箱地址"
+                  icon='mail'
+                  iconPosition='left'
+                  placeholder='输入邮箱地址'
                   onChange={handleChange}
-                  name="email"
-                  type="email"
+                  name='email'
+                  type='email'
                   action={
                     <Button onClick={sendVerificationCode} disabled={loading}>
                       获取验证码
@@ -149,11 +157,11 @@ const RegisterForm = () => {
                 />
                 <Form.Input
                   fluid
-                  icon="lock"
-                  iconPosition="left"
-                  placeholder="输入验证码"
+                  icon='lock'
+                  iconPosition='left'
+                  placeholder='输入验证码'
                   onChange={handleChange}
-                  name="verification_code"
+                  name='verification_code'
                 />
               </>
             ) : (
@@ -170,9 +178,9 @@ const RegisterForm = () => {
               <></>
             )}
             <Button
-              color="green"
+              color='green'
               fluid
-              size="large"
+              size='large'
               onClick={handleSubmit}
               loading={loading}
             >
@@ -182,7 +190,7 @@ const RegisterForm = () => {
         </Form>
         <Message>
           已有账户?
-          <Link to="/login" className="btn btn-link">
+          <Link to='/login' className='btn btn-link'>
             点击登录
           </Link>
         </Message>

+ 133 - 97
web/src/components/SiderBar.js

@@ -3,7 +3,14 @@ import { Link, useNavigate } from 'react-router-dom';
 import { UserContext } from '../context/User';
 import { StatusContext } from '../context/Status';
 
-import { API, getLogo, getSystemName, isAdmin, isMobile, showError } from '../helpers';
+import {
+  API,
+  getLogo,
+  getSystemName,
+  isAdmin,
+  isMobile,
+  showError,
+} from '../helpers';
 import '../index.css';
 
 import {
@@ -17,7 +24,7 @@ import {
   IconKey,
   IconLayers,
   IconSetting,
-  IconUser
+  IconUser,
 } from '@douyinfe/semi-icons';
 import { Layout, Nav } from '@douyinfe/semi-ui';
 
@@ -26,7 +33,8 @@ import { Layout, Nav } from '@douyinfe/semi-ui';
 const SiderBar = () => {
   const [userState, userDispatch] = useContext(UserContext);
   const [statusState, statusDispatch] = useContext(StatusContext);
-  const defaultIsCollapsed = isMobile() || localStorage.getItem('default_collapse_sidebar') === 'true';
+  const defaultIsCollapsed =
+    isMobile() || localStorage.getItem('default_collapse_sidebar') === 'true';
 
   let navigate = useNavigate();
   const [selectedKeys, setSelectedKeys] = useState(['home']);
@@ -46,89 +54,105 @@ const SiderBar = () => {
     setting: '/setting',
     about: '/about',
     chat: '/chat',
-    detail: '/detail'
+    detail: '/detail',
   };
 
-  const headerButtons = useMemo(() => [
-    {
-      text: '首页',
-      itemKey: 'home',
-      to: '/',
-      icon: <IconHome />
-    },
-    {
-      text: '渠道',
-      itemKey: 'channel',
-      to: '/channel',
-      icon: <IconLayers />,
-      className: isAdmin() ? 'semi-navigation-item-normal' : 'tableHiddle'
-    },
-    {
-      text: '聊天',
-      itemKey: 'chat',
-      to: '/chat',
-      icon: <IconComment />,
-      className: localStorage.getItem('chat_link') ? 'semi-navigation-item-normal' : 'tableHiddle'
-    },
-    {
-      text: '令牌',
-      itemKey: 'token',
-      to: '/token',
-      icon: <IconKey />
-    },
-    {
-      text: '兑换码',
-      itemKey: 'redemption',
-      to: '/redemption',
-      icon: <IconGift />,
-      className: isAdmin() ? 'semi-navigation-item-normal' : 'tableHiddle'
-    },
-    {
-      text: '钱包',
-      itemKey: 'topup',
-      to: '/topup',
-      icon: <IconCreditCard />
-    },
-    {
-      text: '用户管理',
-      itemKey: 'user',
-      to: '/user',
-      icon: <IconUser />,
-      className: isAdmin() ? 'semi-navigation-item-normal' : 'tableHiddle'
-    },
-    {
-      text: '日志',
-      itemKey: 'log',
-      to: '/log',
-      icon: <IconHistogram />
-    },
-    {
-      text: '数据看板',
-      itemKey: 'detail',
-      to: '/detail',
-      icon: <IconCalendarClock />,
-      className: localStorage.getItem('enable_data_export') === 'true' ? 'semi-navigation-item-normal' : 'tableHiddle'
-    },
-    {
-      text: '绘图',
-      itemKey: 'midjourney',
-      to: '/midjourney',
-      icon: <IconImage />,
-      className: localStorage.getItem('enable_drawing') === 'true' ? 'semi-navigation-item-normal' : 'tableHiddle'
-    },
-    {
-      text: '设置',
-      itemKey: 'setting',
-      to: '/setting',
-      icon: <IconSetting />
-    }
-    // {
-    //     text: '关于',
-    //     itemKey: 'about',
-    //     to: '/about',
-    //     icon: <IconAt/>
-    // }
-  ], [localStorage.getItem('enable_data_export'), localStorage.getItem('enable_drawing'), localStorage.getItem('chat_link'), isAdmin()]);
+  const headerButtons = useMemo(
+    () => [
+      {
+        text: '首页',
+        itemKey: 'home',
+        to: '/',
+        icon: <IconHome />,
+      },
+      {
+        text: '渠道',
+        itemKey: 'channel',
+        to: '/channel',
+        icon: <IconLayers />,
+        className: isAdmin() ? 'semi-navigation-item-normal' : 'tableHiddle',
+      },
+      {
+        text: '聊天',
+        itemKey: 'chat',
+        to: '/chat',
+        icon: <IconComment />,
+        className: localStorage.getItem('chat_link')
+          ? 'semi-navigation-item-normal'
+          : 'tableHiddle',
+      },
+      {
+        text: '令牌',
+        itemKey: 'token',
+        to: '/token',
+        icon: <IconKey />,
+      },
+      {
+        text: '兑换码',
+        itemKey: 'redemption',
+        to: '/redemption',
+        icon: <IconGift />,
+        className: isAdmin() ? 'semi-navigation-item-normal' : 'tableHiddle',
+      },
+      {
+        text: '钱包',
+        itemKey: 'topup',
+        to: '/topup',
+        icon: <IconCreditCard />,
+      },
+      {
+        text: '用户管理',
+        itemKey: 'user',
+        to: '/user',
+        icon: <IconUser />,
+        className: isAdmin() ? 'semi-navigation-item-normal' : 'tableHiddle',
+      },
+      {
+        text: '日志',
+        itemKey: 'log',
+        to: '/log',
+        icon: <IconHistogram />,
+      },
+      {
+        text: '数据看板',
+        itemKey: 'detail',
+        to: '/detail',
+        icon: <IconCalendarClock />,
+        className:
+          localStorage.getItem('enable_data_export') === 'true'
+            ? 'semi-navigation-item-normal'
+            : 'tableHiddle',
+      },
+      {
+        text: '绘图',
+        itemKey: 'midjourney',
+        to: '/midjourney',
+        icon: <IconImage />,
+        className:
+          localStorage.getItem('enable_drawing') === 'true'
+            ? 'semi-navigation-item-normal'
+            : 'tableHiddle',
+      },
+      {
+        text: '设置',
+        itemKey: 'setting',
+        to: '/setting',
+        icon: <IconSetting />,
+      },
+      // {
+      //     text: '关于',
+      //     itemKey: 'about',
+      //     to: '/about',
+      //     icon: <IconAt/>
+      // }
+    ],
+    [
+      localStorage.getItem('enable_data_export'),
+      localStorage.getItem('enable_drawing'),
+      localStorage.getItem('chat_link'),
+      isAdmin(),
+    ],
+  );
 
   const loadStatus = async () => {
     const res = await API.get('/api/status');
@@ -143,8 +167,14 @@ const SiderBar = () => {
       localStorage.setItem('display_in_currency', data.display_in_currency);
       localStorage.setItem('enable_drawing', data.enable_drawing);
       localStorage.setItem('enable_data_export', data.enable_data_export);
-      localStorage.setItem('data_export_default_time', data.data_export_default_time);
-      localStorage.setItem('default_collapse_sidebar', data.default_collapse_sidebar);
+      localStorage.setItem(
+        'data_export_default_time',
+        data.data_export_default_time,
+      );
+      localStorage.setItem(
+        'default_collapse_sidebar',
+        data.default_collapse_sidebar,
+      );
       localStorage.setItem('mj_notify_enabled', data.mj_notify_enabled);
       if (data.chat_link) {
         localStorage.setItem('chat_link', data.chat_link);
@@ -163,11 +193,14 @@ const SiderBar = () => {
 
   useEffect(() => {
     loadStatus().then(() => {
-      setIsCollapsed(isMobile() || localStorage.getItem('default_collapse_sidebar') === 'true');
+      setIsCollapsed(
+        isMobile() ||
+          localStorage.getItem('default_collapse_sidebar') === 'true',
+      );
     });
-    let localKey = window.location.pathname.split('/')[1]
+    let localKey = window.location.pathname.split('/')[1];
     if (localKey === '') {
-      localKey = 'home'
+      localKey = 'home';
     }
     setSelectedKeys([localKey]);
   }, []);
@@ -179,9 +212,12 @@ const SiderBar = () => {
           <Nav
             // bodyStyle={{ maxWidth: 200 }}
             style={{ maxWidth: 200 }}
-            defaultIsCollapsed={isMobile() || localStorage.getItem('default_collapse_sidebar') === 'true'}
+            defaultIsCollapsed={
+              isMobile() ||
+              localStorage.getItem('default_collapse_sidebar') === 'true'
+            }
             isCollapsed={isCollapsed}
-            onCollapseChange={collapsed => {
+            onCollapseChange={(collapsed) => {
               setIsCollapsed(collapsed);
             }}
             selectedKeys={selectedKeys}
@@ -196,20 +232,20 @@ const SiderBar = () => {
               );
             }}
             items={headerButtons}
-            onSelect={key => {
+            onSelect={(key) => {
               setSelectedKeys([key.itemKey]);
             }}
             header={{
-              logo: <img src={logo} alt="logo" style={{ marginRight: '0.75em' }} />,
-              text: systemName
+              logo: (
+                <img src={logo} alt='logo' style={{ marginRight: '0.75em' }} />
+              ),
+              text: systemName,
             }}
             // footer={{
             //   text: '© 2021 NekoAPI',
             // }}
           >
-
-            <Nav.Footer collapseButton={true}>
-            </Nav.Footer>
+            <Nav.Footer collapseButton={true}></Nav.Footer>
           </Nav>
         </div>
       </Layout>

+ 202 - 161
web/src/components/SystemSetting.js

@@ -1,5 +1,13 @@
 import React, { useEffect, useState } from 'react';
-import { Button, Divider, Form, Grid, Header, Message, Modal } from 'semantic-ui-react';
+import {
+  Button,
+  Divider,
+  Form,
+  Grid,
+  Header,
+  Message,
+  Modal,
+} from 'semantic-ui-react';
 import { API, removeTrailingSlash, showError, verifyJSON } from '../helpers';
 
 const SystemSetting = () => {
@@ -38,13 +46,14 @@ const SystemSetting = () => {
     // telegram login
     TelegramOAuthEnabled: '',
     TelegramBotToken: '',
-    TelegramBotName: ''
+    TelegramBotName: '',
   });
   const [originInputs, setOriginInputs] = useState({});
   let [loading, setLoading] = useState(false);
   const [EmailDomainWhitelist, setEmailDomainWhitelist] = useState([]);
   const [restrictedDomainInput, setRestrictedDomainInput] = useState('');
-  const [showPasswordWarningModal, setShowPasswordWarningModal] = useState(false);
+  const [showPasswordWarningModal, setShowPasswordWarningModal] =
+    useState(false);
 
   const getOptions = async () => {
     const res = await API.get('/api/option/');
@@ -59,13 +68,15 @@ const SystemSetting = () => {
       });
       setInputs({
         ...newInputs,
-        EmailDomainWhitelist: newInputs.EmailDomainWhitelist.split(',')
+        EmailDomainWhitelist: newInputs.EmailDomainWhitelist.split(','),
       });
       setOriginInputs(newInputs);
 
-      setEmailDomainWhitelist(newInputs.EmailDomainWhitelist.split(',').map((item) => {
-        return { key: item, text: item, value: item };
-      }));
+      setEmailDomainWhitelist(
+        newInputs.EmailDomainWhitelist.split(',').map((item) => {
+          return { key: item, text: item, value: item };
+        }),
+      );
     } else {
       showError(message);
     }
@@ -94,7 +105,7 @@ const SystemSetting = () => {
     }
     const res = await API.put('/api/option/', {
       key,
-      value
+      value,
     });
     const { success, message } = res.data;
     if (success) {
@@ -105,7 +116,8 @@ const SystemSetting = () => {
         value = parseFloat(value);
       }
       setInputs((inputs) => ({
-        ...inputs, [key]: value
+        ...inputs,
+        [key]: value,
       }));
     } else {
       showError(message);
@@ -197,13 +209,16 @@ const SystemSetting = () => {
     }
   };
 
-
   const submitEmailDomainWhitelist = async () => {
     if (
-      originInputs['EmailDomainWhitelist'] !== inputs.EmailDomainWhitelist.join(',') &&
+      originInputs['EmailDomainWhitelist'] !==
+        inputs.EmailDomainWhitelist.join(',') &&
       inputs.SMTPToken !== ''
     ) {
-      await updateOption('EmailDomainWhitelist', inputs.EmailDomainWhitelist.join(','));
+      await updateOption(
+        'EmailDomainWhitelist',
+        inputs.EmailDomainWhitelist.join(','),
+      );
     }
   };
 
@@ -211,7 +226,7 @@ const SystemSetting = () => {
     if (originInputs['WeChatServerAddress'] !== inputs.WeChatServerAddress) {
       await updateOption(
         'WeChatServerAddress',
-        removeTrailingSlash(inputs.WeChatServerAddress)
+        removeTrailingSlash(inputs.WeChatServerAddress),
       );
     }
     if (
@@ -220,7 +235,7 @@ const SystemSetting = () => {
     ) {
       await updateOption(
         'WeChatAccountQRCodeImageURL',
-        inputs.WeChatAccountQRCodeImageURL
+        inputs.WeChatAccountQRCodeImageURL,
       );
     }
     if (
@@ -263,17 +278,23 @@ const SystemSetting = () => {
 
   const submitNewRestrictedDomain = () => {
     const localDomainList = inputs.EmailDomainWhitelist;
-    if (restrictedDomainInput !== '' && !localDomainList.includes(restrictedDomainInput)) {
+    if (
+      restrictedDomainInput !== '' &&
+      !localDomainList.includes(restrictedDomainInput)
+    ) {
       setRestrictedDomainInput('');
       setInputs({
         ...inputs,
-        EmailDomainWhitelist: [...localDomainList, restrictedDomainInput]
+        EmailDomainWhitelist: [...localDomainList, restrictedDomainInput],
       });
-      setEmailDomainWhitelist([...EmailDomainWhitelist, {
-        key: restrictedDomainInput,
-        text: restrictedDomainInput,
-        value: restrictedDomainInput
-      }]);
+      setEmailDomainWhitelist([
+        ...EmailDomainWhitelist,
+        {
+          key: restrictedDomainInput,
+          text: restrictedDomainInput,
+          value: restrictedDomainInput,
+        },
+      ]);
     }
   };
 
@@ -281,13 +302,13 @@ const SystemSetting = () => {
     <Grid columns={1}>
       <Grid.Column>
         <Form loading={loading}>
-          <Header as="h3">通用设置</Header>
-          <Form.Group widths="equal">
+          <Header as='h3'>通用设置</Header>
+          <Form.Group widths='equal'>
             <Form.Input
-              label="服务器地址"
-              placeholder="例如:https://yourdomain.com"
+              label='服务器地址'
+              placeholder='例如:https://yourdomain.com'
               value={inputs.ServerAddress}
-              name="ServerAddress"
+              name='ServerAddress'
               onChange={handleInputChange}
             />
           </Form.Group>
@@ -295,81 +316,79 @@ const SystemSetting = () => {
             更新服务器地址
           </Form.Button>
           <Divider />
-          <Header as="h3">支付设置(当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)</Header>
-          <Form.Group widths="equal">
+          <Header as='h3'>
+            支付设置(当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)
+          </Header>
+          <Form.Group widths='equal'>
             <Form.Input
-              label="支付地址,不填写则不启用在线支付"
-              placeholder="例如:https://yourdomain.com"
+              label='支付地址,不填写则不启用在线支付'
+              placeholder='例如:https://yourdomain.com'
               value={inputs.PayAddress}
-              name="PayAddress"
+              name='PayAddress'
               onChange={handleInputChange}
             />
             <Form.Input
-              label="易支付商户ID"
-              placeholder="例如:0001"
+              label='易支付商户ID'
+              placeholder='例如:0001'
               value={inputs.EpayId}
-              name="EpayId"
+              name='EpayId'
               onChange={handleInputChange}
             />
             <Form.Input
-              label="易支付商户密钥"
-              placeholder="例如:dejhfueqhujasjmndbjkqaw"
+              label='易支付商户密钥'
+              placeholder='例如:dejhfueqhujasjmndbjkqaw'
               value={inputs.EpayKey}
-              name="EpayKey"
+              name='EpayKey'
               onChange={handleInputChange}
             />
-
           </Form.Group>
-          <Form.Group widths="equal">
+          <Form.Group widths='equal'>
             <Form.Input
-              label="回调地址,不填写则使用上方服务器地址作为回调地址"
-              placeholder="例如:https://yourdomain.com"
+              label='回调地址,不填写则使用上方服务器地址作为回调地址'
+              placeholder='例如:https://yourdomain.com'
               value={inputs.CustomCallbackAddress}
-              name="CustomCallbackAddress"
+              name='CustomCallbackAddress'
               onChange={handleInputChange}
             />
             <Form.Input
-              label="充值价格(x元/美金)"
-              placeholder="例如:7,就是7元/美金"
+              label='充值价格(x元/美金)'
+              placeholder='例如:7,就是7元/美金'
               value={inputs.Price}
-              name="Price"
+              name='Price'
               min={0}
               onChange={handleInputChange}
             />
             <Form.Input
-              label="最低充值数量"
-              placeholder="例如:2,就是最低充值2$"
+              label='最低充值数量'
+              placeholder='例如:2,就是最低充值2$'
               value={inputs.MinTopUp}
-              name="MinTopUp"
+              name='MinTopUp'
               min={1}
               onChange={handleInputChange}
             />
           </Form.Group>
-          <Form.Group widths="equal">
+          <Form.Group widths='equal'>
             <Form.TextArea
-              label="充值分组倍率"
-              name="TopupGroupRatio"
+              label='充值分组倍率'
+              name='TopupGroupRatio'
               onChange={handleInputChange}
               style={{ minHeight: 250, fontFamily: 'JetBrains Mono, Consolas' }}
-              autoComplete="new-password"
+              autoComplete='new-password'
               value={inputs.TopupGroupRatio}
-              placeholder="为一个 JSON 文本,键为组名称,值为倍率"
+              placeholder='为一个 JSON 文本,键为组名称,值为倍率'
             />
           </Form.Group>
-          <Form.Button onClick={submitPayAddress}>
-            更新支付设置
-          </Form.Button>
+          <Form.Button onClick={submitPayAddress}>更新支付设置</Form.Button>
           <Divider />
-          <Header as="h3">配置登录注册</Header>
+          <Header as='h3'>配置登录注册</Header>
           <Form.Group inline>
             <Form.Checkbox
               checked={inputs.PasswordLoginEnabled === 'true'}
-              label="允许通过密码进行登录"
-              name="PasswordLoginEnabled"
+              label='允许通过密码进行登录'
+              name='PasswordLoginEnabled'
               onChange={handleInputChange}
             />
-            {
-              showPasswordWarningModal &&
+            {showPasswordWarningModal && (
               <Modal
                 open={showPasswordWarningModal}
                 onClose={() => setShowPasswordWarningModal(false)}
@@ -378,12 +397,16 @@ const SystemSetting = () => {
               >
                 <Modal.Header>警告</Modal.Header>
                 <Modal.Content>
-                  <p>取消密码登录将导致所有未绑定其他登录方式的用户(包括管理员)无法通过密码登录,确认取消?</p>
+                  <p>
+                    取消密码登录将导致所有未绑定其他登录方式的用户(包括管理员)无法通过密码登录,确认取消?
+                  </p>
                 </Modal.Content>
                 <Modal.Actions>
-                  <Button onClick={() => setShowPasswordWarningModal(false)}>取消</Button>
+                  <Button onClick={() => setShowPasswordWarningModal(false)}>
+                    取消
+                  </Button>
                   <Button
-                    color="yellow"
+                    color='yellow'
                     onClick={async () => {
                       setShowPasswordWarningModal(false);
                       await updateOption('PasswordLoginEnabled', 'false');
@@ -393,157 +416,170 @@ const SystemSetting = () => {
                   </Button>
                 </Modal.Actions>
               </Modal>
-            }
+            )}
             <Form.Checkbox
               checked={inputs.PasswordRegisterEnabled === 'true'}
-              label="允许通过密码进行注册"
-              name="PasswordRegisterEnabled"
+              label='允许通过密码进行注册'
+              name='PasswordRegisterEnabled'
               onChange={handleInputChange}
             />
             <Form.Checkbox
               checked={inputs.EmailVerificationEnabled === 'true'}
-              label="通过密码注册时需要进行邮箱验证"
-              name="EmailVerificationEnabled"
+              label='通过密码注册时需要进行邮箱验证'
+              name='EmailVerificationEnabled'
               onChange={handleInputChange}
             />
             <Form.Checkbox
               checked={inputs.GitHubOAuthEnabled === 'true'}
-              label="允许通过 GitHub 账户登录 & 注册"
-              name="GitHubOAuthEnabled"
+              label='允许通过 GitHub 账户登录 & 注册'
+              name='GitHubOAuthEnabled'
               onChange={handleInputChange}
             />
             <Form.Checkbox
               checked={inputs.WeChatAuthEnabled === 'true'}
-              label="允许通过微信登录 & 注册"
-              name="WeChatAuthEnabled"
+              label='允许通过微信登录 & 注册'
+              name='WeChatAuthEnabled'
               onChange={handleInputChange}
             />
             <Form.Checkbox
               checked={inputs.TelegramOAuthEnabled === 'true'}
-              label="允许通过 Telegram 进行登录"
-              name="TelegramOAuthEnabled"
+              label='允许通过 Telegram 进行登录'
+              name='TelegramOAuthEnabled'
               onChange={handleInputChange}
             />
           </Form.Group>
           <Form.Group inline>
             <Form.Checkbox
               checked={inputs.RegisterEnabled === 'true'}
-              label="允许新用户注册(此项为否时,新用户将无法以任何方式进行注册)"
-              name="RegisterEnabled"
+              label='允许新用户注册(此项为否时,新用户将无法以任何方式进行注册)'
+              name='RegisterEnabled'
               onChange={handleInputChange}
             />
             <Form.Checkbox
               checked={inputs.TurnstileCheckEnabled === 'true'}
-              label="启用 Turnstile 用户校验"
-              name="TurnstileCheckEnabled"
+              label='启用 Turnstile 用户校验'
+              name='TurnstileCheckEnabled'
               onChange={handleInputChange}
             />
           </Form.Group>
           <Divider />
-          <Header as="h3">
+          <Header as='h3'>
             配置邮箱域名白名单
-            <Header.Subheader>用以防止恶意用户利用临时邮箱批量注册</Header.Subheader>
+            <Header.Subheader>
+              用以防止恶意用户利用临时邮箱批量注册
+            </Header.Subheader>
           </Header>
           <Form.Group widths={3}>
             <Form.Checkbox
-              label="启用邮箱域名白名单"
-              name="EmailDomainRestrictionEnabled"
+              label='启用邮箱域名白名单'
+              name='EmailDomainRestrictionEnabled'
               onChange={handleInputChange}
               checked={inputs.EmailDomainRestrictionEnabled === 'true'}
             />
           </Form.Group>
           <Form.Group widths={2}>
             <Form.Dropdown
-              label="允许的邮箱域名"
-              placeholder="允许的邮箱域名"
-              name="EmailDomainWhitelist"
+              label='允许的邮箱域名'
+              placeholder='允许的邮箱域名'
+              name='EmailDomainWhitelist'
               required
               fluid
               multiple
               selection
               onChange={handleInputChange}
               value={inputs.EmailDomainWhitelist}
-              autoComplete="new-password"
+              autoComplete='new-password'
               options={EmailDomainWhitelist}
             />
             <Form.Input
-              label="添加新的允许的邮箱域名"
+              label='添加新的允许的邮箱域名'
               action={
-                <Button type="button" onClick={() => {
-                  submitNewRestrictedDomain();
-                }}>填入</Button>
+                <Button
+                  type='button'
+                  onClick={() => {
+                    submitNewRestrictedDomain();
+                  }}
+                >
+                  填入
+                </Button>
               }
               onKeyDown={(e) => {
                 if (e.key === 'Enter') {
                   submitNewRestrictedDomain();
                 }
               }}
-              autoComplete="new-password"
-              placeholder="输入新的允许的邮箱域名"
+              autoComplete='new-password'
+              placeholder='输入新的允许的邮箱域名'
               value={restrictedDomainInput}
               onChange={(e, { value }) => {
                 setRestrictedDomainInput(value);
               }}
             />
           </Form.Group>
-          <Form.Button onClick={submitEmailDomainWhitelist}>保存邮箱域名白名单设置</Form.Button>
+          <Form.Button onClick={submitEmailDomainWhitelist}>
+            保存邮箱域名白名单设置
+          </Form.Button>
           <Divider />
-          <Header as="h3">
+          <Header as='h3'>
             配置 SMTP
             <Header.Subheader>用以支持系统的邮件发送</Header.Subheader>
           </Header>
           <Form.Group widths={3}>
             <Form.Input
-              label="SMTP 服务器地址"
-              name="SMTPServer"
+              label='SMTP 服务器地址'
+              name='SMTPServer'
               onChange={handleInputChange}
-              autoComplete="new-password"
+              autoComplete='new-password'
               value={inputs.SMTPServer}
-              placeholder="例如:smtp.qq.com"
+              placeholder='例如:smtp.qq.com'
             />
             <Form.Input
-              label="SMTP 端口"
-              name="SMTPPort"
+              label='SMTP 端口'
+              name='SMTPPort'
               onChange={handleInputChange}
-              autoComplete="new-password"
+              autoComplete='new-password'
               value={inputs.SMTPPort}
-              placeholder="默认: 587"
+              placeholder='默认: 587'
             />
             <Form.Input
-              label="SMTP 账户"
-              name="SMTPAccount"
+              label='SMTP 账户'
+              name='SMTPAccount'
               onChange={handleInputChange}
-              autoComplete="new-password"
+              autoComplete='new-password'
               value={inputs.SMTPAccount}
-              placeholder="通常是邮箱地址"
+              placeholder='通常是邮箱地址'
             />
           </Form.Group>
           <Form.Group widths={3}>
             <Form.Input
-              label="SMTP 发送者邮箱"
-              name="SMTPFrom"
+              label='SMTP 发送者邮箱'
+              name='SMTPFrom'
               onChange={handleInputChange}
-              autoComplete="new-password"
+              autoComplete='new-password'
               value={inputs.SMTPFrom}
-              placeholder="通常和邮箱地址保持一致"
+              placeholder='通常和邮箱地址保持一致'
             />
             <Form.Input
-              label="SMTP 访问凭证"
-              name="SMTPToken"
+              label='SMTP 访问凭证'
+              name='SMTPToken'
               onChange={handleInputChange}
-              type="password"
-              autoComplete="new-password"
+              type='password'
+              autoComplete='new-password'
               checked={inputs.RegisterEnabled === 'true'}
-              placeholder="敏感信息不会发送到前端显示"
+              placeholder='敏感信息不会发送到前端显示'
             />
           </Form.Group>
           <Form.Button onClick={submitSMTP}>保存 SMTP 设置</Form.Button>
           <Divider />
-          <Header as="h3">
+          <Header as='h3'>
             配置 GitHub OAuth App
             <Header.Subheader>
               用以支持通过 GitHub 进行登录注册,
-              <a href="https://github.com/settings/developers" target="_blank" rel="noreferrer">
+              <a
+                href='https://github.com/settings/developers'
+                target='_blank'
+                rel='noreferrer'
+              >
                 点击此处
               </a>
               管理你的 GitHub OAuth App
@@ -556,34 +592,35 @@ const SystemSetting = () => {
           </Message>
           <Form.Group widths={3}>
             <Form.Input
-              label="GitHub Client ID"
-              name="GitHubClientId"
+              label='GitHub Client ID'
+              name='GitHubClientId'
               onChange={handleInputChange}
-              autoComplete="new-password"
+              autoComplete='new-password'
               value={inputs.GitHubClientId}
-              placeholder="输入你注册的 GitHub OAuth APP 的 ID"
+              placeholder='输入你注册的 GitHub OAuth APP 的 ID'
             />
             <Form.Input
-              label="GitHub Client Secret"
-              name="GitHubClientSecret"
+              label='GitHub Client Secret'
+              name='GitHubClientSecret'
               onChange={handleInputChange}
-              type="password"
-              autoComplete="new-password"
+              type='password'
+              autoComplete='new-password'
               value={inputs.GitHubClientSecret}
-              placeholder="敏感信息不会发送到前端显示"
+              placeholder='敏感信息不会发送到前端显示'
             />
           </Form.Group>
           <Form.Button onClick={submitGitHubOAuth}>
             保存 GitHub OAuth 设置
           </Form.Button>
           <Divider />
-          <Header as="h3">
+          <Header as='h3'>
             配置 WeChat Server
             <Header.Subheader>
               用以支持通过微信进行登录注册,
               <a
-                href="https://github.com/songquanpeng/wechat-server"
-                target="_blank" rel="noreferrer"
+                href='https://github.com/songquanpeng/wechat-server'
+                target='_blank'
+                rel='noreferrer'
               >
                 点击此处
               </a>
@@ -592,61 +629,65 @@ const SystemSetting = () => {
           </Header>
           <Form.Group widths={3}>
             <Form.Input
-              label="WeChat Server 服务器地址"
-              name="WeChatServerAddress"
-              placeholder="例如:https://yourdomain.com"
+              label='WeChat Server 服务器地址'
+              name='WeChatServerAddress'
+              placeholder='例如:https://yourdomain.com'
               onChange={handleInputChange}
-              autoComplete="new-password"
+              autoComplete='new-password'
               value={inputs.WeChatServerAddress}
             />
             <Form.Input
-              label="WeChat Server 访问凭证"
-              name="WeChatServerToken"
-              type="password"
+              label='WeChat Server 访问凭证'
+              name='WeChatServerToken'
+              type='password'
               onChange={handleInputChange}
-              autoComplete="new-password"
+              autoComplete='new-password'
               value={inputs.WeChatServerToken}
-              placeholder="敏感信息不会发送到前端显示"
+              placeholder='敏感信息不会发送到前端显示'
             />
             <Form.Input
-              label="微信公众号二维码图片链接"
-              name="WeChatAccountQRCodeImageURL"
+              label='微信公众号二维码图片链接'
+              name='WeChatAccountQRCodeImageURL'
               onChange={handleInputChange}
-              autoComplete="new-password"
+              autoComplete='new-password'
               value={inputs.WeChatAccountQRCodeImageURL}
-              placeholder="输入一个图片链接"
+              placeholder='输入一个图片链接'
             />
           </Form.Group>
           <Form.Button onClick={submitWeChat}>
             保存 WeChat Server 设置
           </Form.Button>
           <Divider />
-          <Header as="h3">配置 Telegram 登录</Header>
+          <Header as='h3'>配置 Telegram 登录</Header>
           <Form.Group inline>
             <Form.Input
-              label="Telegram Bot Token"
-              name="TelegramBotToken"
+              label='Telegram Bot Token'
+              name='TelegramBotToken'
               onChange={handleInputChange}
               value={inputs.TelegramBotToken}
-              placeholder="输入你的 Telegram Bot Token"
+              placeholder='输入你的 Telegram Bot Token'
             />
             <Form.Input
-              label="Telegram Bot 名称"
-              name="TelegramBotName"
+              label='Telegram Bot 名称'
+              name='TelegramBotName'
               onChange={handleInputChange}
               value={inputs.TelegramBotName}
-              placeholder="输入你的 Telegram Bot 名称"
+              placeholder='输入你的 Telegram Bot 名称'
             />
           </Form.Group>
           <Form.Button onClick={submitTelegramSettings}>
             保存 Telegram 登录设置
           </Form.Button>
           <Divider />
-          <Header as="h3">
+          <Header as='h3'>
             配置 Turnstile
             <Header.Subheader>
               用以支持用户校验,
-              <a href="https://dash.cloudflare.com/" target="_blank" rel="noreferrer">
+              <a
+                href='https://dash.cloudflare.com/'
+                target='_blank'
+                rel='noreferrer'
+              >
                 点击此处
               </a>
               管理你的 Turnstile Sites,推荐选择 Invisible Widget Type
@@ -654,21 +695,21 @@ const SystemSetting = () => {
           </Header>
           <Form.Group widths={3}>
             <Form.Input
-              label="Turnstile Site Key"
-              name="TurnstileSiteKey"
+              label='Turnstile Site Key'
+              name='TurnstileSiteKey'
               onChange={handleInputChange}
-              autoComplete="new-password"
+              autoComplete='new-password'
               value={inputs.TurnstileSiteKey}
-              placeholder="输入你注册的 Turnstile Site Key"
+              placeholder='输入你注册的 Turnstile Site Key'
             />
             <Form.Input
-              label="Turnstile Secret Key"
-              name="TurnstileSecretKey"
+              label='Turnstile Secret Key'
+              name='TurnstileSecretKey'
               onChange={handleInputChange}
-              type="password"
-              autoComplete="new-password"
+              type='password'
+              autoComplete='new-password'
               value={inputs.TurnstileSecretKey}
-              placeholder="敏感信息不会发送到前端显示"
+              placeholder='敏感信息不会发送到前端显示'
             />
           </Form.Group>
           <Form.Button onClick={submitTurnstile}>

+ 276 - 162
web/src/components/TokensTable.js

@@ -1,9 +1,25 @@
 import React, { useEffect, useState } from 'react';
-import { API, copy, showError, showSuccess, timestamp2string } from '../helpers';
+import {
+  API,
+  copy,
+  showError,
+  showSuccess,
+  timestamp2string,
+} from '../helpers';
 
 import { ITEMS_PER_PAGE } from '../constants';
 import { renderQuota } from '../helpers/render';
-import { Button, Dropdown, Form, Modal, Popconfirm, Popover, SplitButtonGroup, Table, Tag } from '@douyinfe/semi-ui';
+import {
+  Button,
+  Dropdown,
+  Form,
+  Modal,
+  Popconfirm,
+  Popover,
+  SplitButtonGroup,
+  Table,
+  Tag,
+} from '@douyinfe/semi-ui';
 
 import { IconTreeTriangleDown } from '@douyinfe/semi-icons';
 import EditToken from '../pages/Token/EditToken';
@@ -11,85 +27,107 @@ import EditToken from '../pages/Token/EditToken';
 const COPY_OPTIONS = [
   { key: 'next', text: 'ChatGPT Next Web', value: 'next' },
   { key: 'ama', text: 'ChatGPT Web & Midjourney', value: 'ama' },
-  { key: 'opencat', text: 'OpenCat', value: 'opencat' }
+  { key: 'opencat', text: 'OpenCat', value: 'opencat' },
 ];
 
 const OPEN_LINK_OPTIONS = [
   { key: 'ama', text: 'ChatGPT Web & Midjourney', value: 'ama' },
-  { key: 'opencat', text: 'OpenCat', value: 'opencat' }
+  { key: 'opencat', text: 'OpenCat', value: 'opencat' },
 ];
 
 function renderTimestamp(timestamp) {
-  return (
-    <>
-      {timestamp2string(timestamp)}
-    </>
-  );
+  return <>{timestamp2string(timestamp)}</>;
 }
 
 function renderStatus(status, model_limits_enabled = false) {
   switch (status) {
     case 1:
       if (model_limits_enabled) {
-        return <Tag color="green" size="large">已启用:限制模型</Tag>;
+        return (
+          <Tag color='green' size='large'>
+            已启用:限制模型
+          </Tag>
+        );
       } else {
-        return <Tag color="green" size="large">已启用</Tag>;
+        return (
+          <Tag color='green' size='large'>
+            已启用
+          </Tag>
+        );
       }
     case 2:
-      return <Tag color="red" size="large"> 已禁用 </Tag>;
+      return (
+        <Tag color='red' size='large'>
+          {' '}
+          已禁用{' '}
+        </Tag>
+      );
     case 3:
-      return <Tag color="yellow" size="large"> 已过期 </Tag>;
+      return (
+        <Tag color='yellow' size='large'>
+          {' '}
+          已过期{' '}
+        </Tag>
+      );
     case 4:
-      return <Tag color="grey" size="large"> 已耗尽 </Tag>;
+      return (
+        <Tag color='grey' size='large'>
+          {' '}
+          已耗尽{' '}
+        </Tag>
+      );
     default:
-      return <Tag color="black" size="large"> 未知状态 </Tag>;
+      return (
+        <Tag color='black' size='large'>
+          {' '}
+          未知状态{' '}
+        </Tag>
+      );
   }
 }
 
 const TokensTable = () => {
-
   const link_menu = [
     {
-      node: 'item', key: 'next', name: 'ChatGPT Next Web', onClick: () => {
+      node: 'item',
+      key: 'next',
+      name: 'ChatGPT Next Web',
+      onClick: () => {
         onOpenLink('next');
-      }
+      },
     },
     { node: 'item', key: 'ama', name: 'AMA 问天', value: 'ama' },
     {
-      node: 'item', key: 'next-mj', name: 'ChatGPT Web & Midjourney', value: 'next-mj', onClick: () => {
+      node: 'item',
+      key: 'next-mj',
+      name: 'ChatGPT Web & Midjourney',
+      value: 'next-mj',
+      onClick: () => {
         onOpenLink('next-mj');
-      }
+      },
     },
-    { node: 'item', key: 'opencat', name: 'OpenCat', value: 'opencat' }
+    { node: 'item', key: 'opencat', name: 'OpenCat', value: 'opencat' },
   ];
 
   const columns = [
     {
       title: '名称',
-      dataIndex: 'name'
+      dataIndex: 'name',
     },
     {
       title: '状态',
       dataIndex: 'status',
       key: 'status',
       render: (text, record, index) => {
-        return (
-          <div>
-            {renderStatus(text, record.model_limits_enabled)}
-          </div>
-        );
-      }
+        return <div>{renderStatus(text, record.model_limits_enabled)}</div>;
+      },
     },
     {
       title: '已用额度',
       dataIndex: 'used_quota',
       render: (text, record, index) => {
-        return (
-          <div>
-            {renderQuota(parseInt(text))}
-          </div>
-        );
-      }
+        return <div>{renderQuota(parseInt(text))}</div>;
+      },
     },
     {
       title: '剩余额度',
@@ -97,22 +135,25 @@ const TokensTable = () => {
       render: (text, record, index) => {
         return (
           <div>
-            {record.unlimited_quota ? <Tag size={'large'} color={'white'}>无限制</Tag> :
-              <Tag size={'large'} color={'light-blue'}>{renderQuota(parseInt(text))}</Tag>}
+            {record.unlimited_quota ? (
+              <Tag size={'large'} color={'white'}>
+                无限制
+              </Tag>
+            ) : (
+              <Tag size={'large'} color={'light-blue'}>
+                {renderQuota(parseInt(text))}
+              </Tag>
+            )}
           </div>
         );
-      }
+      },
     },
     {
       title: '创建时间',
       dataIndex: 'created_time',
       render: (text, record, index) => {
-        return (
-          <div>
-            {renderTimestamp(text)}
-          </div>
-        );
-      }
+        return <div>{renderTimestamp(text)}</div>;
+      },
     },
     {
       title: '过期时间',
@@ -123,7 +164,7 @@ const TokensTable = () => {
             {record.expired_time === -1 ? '永不过期' : renderTimestamp(text)}
           </div>
         );
-      }
+      },
     },
     {
       title: '',
@@ -131,25 +172,41 @@ const TokensTable = () => {
       render: (text, record, index) => (
         <div>
           <Popover
-            content={
-              'sk-' + record.key
-            }
+            content={'sk-' + record.key}
             style={{ padding: 20 }}
-            position="top"
+            position='top'
           >
-            <Button theme="light" type="tertiary" style={{ marginRight: 1 }}>查看</Button>
+            <Button theme='light' type='tertiary' style={{ marginRight: 1 }}>
+              查看
+            </Button>
           </Popover>
-          <Button theme="light" type="secondary" style={{ marginRight: 1 }}
-                  onClick={async (text) => {
-                    await copyText('sk-' + record.key);
-                  }}
-          >复制</Button>
-          <SplitButtonGroup style={{ marginRight: 1 }} aria-label="项目操作按钮组">
-            <Button theme="light" style={{ color: 'rgba(var(--semi-teal-7), 1)' }} onClick={() => {
-              onOpenLink('next', record.key);
-            }}>聊天</Button>
-            <Dropdown trigger="click" position="bottomRight" menu={
-              [
+          <Button
+            theme='light'
+            type='secondary'
+            style={{ marginRight: 1 }}
+            onClick={async (text) => {
+              await copyText('sk-' + record.key);
+            }}
+          >
+            复制
+          </Button>
+          <SplitButtonGroup
+            style={{ marginRight: 1 }}
+            aria-label='项目操作按钮组'
+          >
+            <Button
+              theme='light'
+              style={{ color: 'rgba(var(--semi-teal-7), 1)' }}
+              onClick={() => {
+                onOpenLink('next', record.key);
+              }}
+            >
+              聊天
+            </Button>
+            <Dropdown
+              trigger='click'
+              position='bottomRight'
+              menu={[
                 {
                   node: 'item',
                   key: 'next',
@@ -157,7 +214,7 @@ const TokensTable = () => {
                   name: 'ChatGPT Next Web',
                   onClick: () => {
                     onOpenLink('next', record.key);
-                  }
+                  },
                 },
                 {
                   node: 'item',
@@ -166,70 +223,88 @@ const TokensTable = () => {
                   name: 'ChatGPT Web & Midjourney',
                   onClick: () => {
                     onOpenLink('next-mj', record.key);
-                  }
+                  },
                 },
                 {
-                  node: 'item', key: 'ama', name: 'AMA 问天(BotGem)', onClick: () => {
+                  node: 'item',
+                  key: 'ama',
+                  name: 'AMA 问天(BotGem)',
+                  onClick: () => {
                     onOpenLink('ama', record.key);
-                  }
+                  },
                 },
                 {
-                  node: 'item', key: 'opencat', name: 'OpenCat', onClick: () => {
+                  node: 'item',
+                  key: 'opencat',
+                  name: 'OpenCat',
+                  onClick: () => {
                     onOpenLink('opencat', record.key);
-                  }
-                }
-              ]
-            }
+                  },
+                },
+              ]}
             >
-              <Button style={{ padding: '8px 4px', color: 'rgba(var(--semi-teal-7), 1)' }} type="primary"
-                      icon={<IconTreeTriangleDown />}></Button>
+              <Button
+                style={{
+                  padding: '8px 4px',
+                  color: 'rgba(var(--semi-teal-7), 1)',
+                }}
+                type='primary'
+                icon={<IconTreeTriangleDown />}
+              ></Button>
             </Dropdown>
           </SplitButtonGroup>
           <Popconfirm
-            title="确定是否要删除此令牌?"
-            content="此修改将不可逆"
+            title='确定是否要删除此令牌?'
+            content='此修改将不可逆'
             okType={'danger'}
             position={'left'}
             onConfirm={() => {
-              manageToken(record.id, 'delete', record).then(
-                () => {
-                  removeRecord(record.key);
-                }
-              );
+              manageToken(record.id, 'delete', record).then(() => {
+                removeRecord(record.key);
+              });
             }}
           >
-            <Button theme="light" type="danger" style={{ marginRight: 1 }}>删除</Button>
+            <Button theme='light' type='danger' style={{ marginRight: 1 }}>
+              删除
+            </Button>
           </Popconfirm>
-          {
-            record.status === 1 ?
-              <Button theme="light" type="warning" style={{ marginRight: 1 }} onClick={
-                async () => {
-                  manageToken(
-                    record.id,
-                    'disable',
-                    record
-                  );
-                }
-              }>禁用</Button> :
-              <Button theme="light" type="secondary" style={{ marginRight: 1 }} onClick={
-                async () => {
-                  manageToken(
-                    record.id,
-                    'enable',
-                    record
-                  );
-                }
-              }>启用</Button>
-          }
-          <Button theme="light" type="tertiary" style={{ marginRight: 1 }} onClick={
-            () => {
+          {record.status === 1 ? (
+            <Button
+              theme='light'
+              type='warning'
+              style={{ marginRight: 1 }}
+              onClick={async () => {
+                manageToken(record.id, 'disable', record);
+              }}
+            >
+              禁用
+            </Button>
+          ) : (
+            <Button
+              theme='light'
+              type='secondary'
+              style={{ marginRight: 1 }}
+              onClick={async () => {
+                manageToken(record.id, 'enable', record);
+              }}
+            >
+              启用
+            </Button>
+          )}
+          <Button
+            theme='light'
+            type='tertiary'
+            style={{ marginRight: 1 }}
+            onClick={() => {
               setEditingToken(record);
               setShowEdit(true);
-            }
-          }>编辑</Button>
+            }}
+          >
+            编辑
+          </Button>
         </div>
-      )
-    }
+      ),
+    },
   ];
 
   const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
@@ -245,14 +320,14 @@ const TokensTable = () => {
   const [showTopUpModal, setShowTopUpModal] = useState(false);
   const [targetTokenIdx, setTargetTokenIdx] = useState(0);
   const [editingToken, setEditingToken] = useState({
-    id: undefined
+    id: undefined,
   });
 
   const closeEdit = () => {
     setShowEdit(false);
     setTimeout(() => {
       setEditingToken({
-        id: undefined
+        id: undefined,
       });
     }, 500);
   };
@@ -266,7 +341,10 @@ const TokensTable = () => {
     }
   };
 
-  let pageData = tokens.slice((activePage - 1) * pageSize, activePage * pageSize);
+  let pageData = tokens.slice(
+    (activePage - 1) * pageSize,
+    activePage * pageSize,
+  );
   const loadTokens = async (startIdx) => {
     setLoading(true);
     const res = await API.get(`/api/token/?p=${startIdx}&size=${pageSize}`);
@@ -315,7 +393,8 @@ const TokensTable = () => {
     let nextUrl;
 
     if (nextLink) {
-      nextUrl = nextLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
+      nextUrl =
+        nextLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
     } else {
       nextUrl = `https://chat.oneapi.pro/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
     }
@@ -323,7 +402,8 @@ const TokensTable = () => {
     let url;
     switch (type) {
       case 'ama':
-        url = mjLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
+        url =
+          mjLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
         break;
       case 'opencat':
         url = `opencat://team/join?domain=${encodedServerAddress}&token=sk-${key}`;
@@ -367,7 +447,8 @@ const TokensTable = () => {
     let defaultUrl;
 
     if (chatLink) {
-      defaultUrl = chatLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
+      defaultUrl =
+        chatLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
     }
     let url;
     switch (type) {
@@ -378,7 +459,8 @@ const TokensTable = () => {
         url = `opencat://team/join?domain=${encodedServerAddress}&token=sk-${key}`;
         break;
       case 'next-mj':
-        url = mjLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
+        url =
+          mjLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
         break;
       default:
         if (!chatLink) {
@@ -399,10 +481,10 @@ const TokensTable = () => {
       });
   }, [pageSize]);
 
-  const removeRecord = key => {
+  const removeRecord = (key) => {
     let newDataSource = [...tokens];
     if (key != null) {
-      let idx = newDataSource.findIndex(data => data.key === key);
+      let idx = newDataSource.findIndex((data) => data.key === key);
 
       if (idx > -1) {
         newDataSource.splice(idx, 1);
@@ -435,7 +517,6 @@ const TokensTable = () => {
       let newTokens = [...tokens];
       // let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
       if (action === 'delete') {
-
       } else {
         record.status = token.status;
         // newTokens[realIdx].status = token.status;
@@ -455,7 +536,9 @@ const TokensTable = () => {
       return;
     }
     setSearching(true);
-    const res = await API.get(`/api/token/search?keyword=${searchKeyword}&token=${searchToken}`);
+    const res = await API.get(
+      `/api/token/search?keyword=${searchKeyword}&token=${searchToken}`,
+    );
     const { success, message, data } = res.data;
     if (success) {
       setTokensFormat(data);
@@ -488,32 +571,28 @@ const TokensTable = () => {
     setLoading(false);
   };
 
-
-  const handlePageChange = page => {
+  const handlePageChange = (page) => {
     setActivePage(page);
     if (page === Math.ceil(tokens.length / pageSize) + 1) {
       // In this case we have to load more data and then append them.
-      loadTokens(page - 1).then(r => {
-      });
+      loadTokens(page - 1).then((r) => {});
     }
   };
 
   const rowSelection = {
-    onSelect: (record, selected) => {
-    },
-    onSelectAll: (selected, selectedRows) => {
-    },
+    onSelect: (record, selected) => {},
+    onSelectAll: (selected, selectedRows) => {},
     onChange: (selectedRowKeys, selectedRows) => {
       setSelectedKeys(selectedRows);
-    }
+    },
   };
 
   const handleRow = (record, index) => {
     if (record.status !== 1) {
       return {
         style: {
-          background: 'var(--semi-color-disabled-border)'
-        }
+          background: 'var(--semi-color-disabled-border)',
+        },
       };
     } else {
       return {};
@@ -522,63 +601,98 @@ const TokensTable = () => {
 
   return (
     <>
-      <EditToken refresh={refresh} editingToken={editingToken} visiable={showEdit} handleClose={closeEdit}></EditToken>
-      <Form layout="horizontal" style={{ marginTop: 10 }} labelPosition={'left'}>
+      <EditToken
+        refresh={refresh}
+        editingToken={editingToken}
+        visiable={showEdit}
+        handleClose={closeEdit}
+      ></EditToken>
+      <Form
+        layout='horizontal'
+        style={{ marginTop: 10 }}
+        labelPosition={'left'}
+      >
         <Form.Input
-          field="keyword"
-          label="搜索关键字"
-          placeholder="令牌名称"
+          field='keyword'
+          label='搜索关键字'
+          placeholder='令牌名称'
           value={searchKeyword}
           loading={searching}
           onChange={handleKeywordChange}
         />
         <Form.Input
-          field="token"
-          label="Key"
-          placeholder="密钥"
+          field='token'
+          label='Key'
+          placeholder='密钥'
           value={searchToken}
           loading={searching}
           onChange={handleSearchTokenChange}
         />
-        <Button label="查询" type="primary" htmlType="submit" className="btn-margin-right"
-                onClick={searchTokens} style={{ marginRight: 8 }}>查询</Button>
+        <Button
+          label='查询'
+          type='primary'
+          htmlType='submit'
+          className='btn-margin-right'
+          onClick={searchTokens}
+          style={{ marginRight: 8 }}
+        >
+          查询
+        </Button>
       </Form>
 
-      <Table style={{ marginTop: 20 }} columns={columns} dataSource={pageData} pagination={{
-        currentPage: activePage,
-        pageSize: pageSize,
-        total: tokenCount,
-        showSizeChanger: true,
-        pageSizeOptions: [10, 20, 50, 100],
-        formatPageText: (page) => `第 ${page.currentStart} - ${page.currentEnd} 条,共 ${tokens.length} 条`,
-        onPageSizeChange: (size) => {
-          setPageSize(size);
-          setActivePage(1);
-        },
-        onPageChange: handlePageChange
-      }} loading={loading} rowSelection={rowSelection} onRow={handleRow}>
-      </Table>
-      <Button theme="light" type="primary" style={{ marginRight: 8 }} onClick={
-        () => {
+      <Table
+        style={{ marginTop: 20 }}
+        columns={columns}
+        dataSource={pageData}
+        pagination={{
+          currentPage: activePage,
+          pageSize: pageSize,
+          total: tokenCount,
+          showSizeChanger: true,
+          pageSizeOptions: [10, 20, 50, 100],
+          formatPageText: (page) =>
+            `第 ${page.currentStart} - ${page.currentEnd} 条,共 ${tokens.length} 条`,
+          onPageSizeChange: (size) => {
+            setPageSize(size);
+            setActivePage(1);
+          },
+          onPageChange: handlePageChange,
+        }}
+        loading={loading}
+        rowSelection={rowSelection}
+        onRow={handleRow}
+      ></Table>
+      <Button
+        theme='light'
+        type='primary'
+        style={{ marginRight: 8 }}
+        onClick={() => {
           setEditingToken({
-            id: undefined
+            id: undefined,
           });
           setShowEdit(true);
-        }
-      }>添加令牌</Button>
-      <Button label="复制所选令牌" type="warning" onClick={
-        async () => {
+        }}
+      >
+        添加令牌
+      </Button>
+      <Button
+        label='复制所选令牌'
+        type='warning'
+        onClick={async () => {
           if (selectedKeys.length === 0) {
             showError('请至少选择一个令牌!');
             return;
           }
           let keys = '';
           for (let i = 0; i < selectedKeys.length; i++) {
-            keys += selectedKeys[i].name + '    sk-' + selectedKeys[i].key + '\n';
+            keys +=
+              selectedKeys[i].name + '    sk-' + selectedKeys[i].key + '\n';
           }
           await copyText(keys);
-        }
-      }>复制所选令牌到剪贴板</Button>
+        }}
+      >
+        复制所选令牌到剪贴板
+      </Button>
     </>
   );
 };

+ 276 - 150
web/src/components/UsersTable.js

@@ -1,6 +1,14 @@
 import React, { useEffect, useState } from 'react';
 import { API, showError, showSuccess } from '../helpers';
-import { Button, Form, Popconfirm, Space, Table, Tag, Tooltip } from '@douyinfe/semi-ui';
+import {
+  Button,
+  Form,
+  Popconfirm,
+  Space,
+  Table,
+  Tag,
+  Tooltip,
+} from '@douyinfe/semi-ui';
 import { ITEMS_PER_PAGE } from '../constants';
 import { renderGroup, renderNumber, renderQuota } from '../helpers/render';
 import AddUser from '../pages/User/AddUser';
@@ -9,124 +17,218 @@ import EditUser from '../pages/User/EditUser';
 function renderRole(role) {
   switch (role) {
     case 1:
-      return <Tag size="large">普通用户</Tag>;
+      return <Tag size='large'>普通用户</Tag>;
     case 10:
-      return <Tag color="yellow" size="large">管理员</Tag>;
+      return (
+        <Tag color='yellow' size='large'>
+          管理员
+        </Tag>
+      );
     case 100:
-      return <Tag color="orange" size="large">超级管理员</Tag>;
+      return (
+        <Tag color='orange' size='large'>
+          超级管理员
+        </Tag>
+      );
     default:
-      return <Tag color="red" size="large">未知身份</Tag>;
+      return (
+        <Tag color='red' size='large'>
+          未知身份
+        </Tag>
+      );
   }
 }
 
 const UsersTable = () => {
-  const columns = [{
-    title: 'ID', dataIndex: 'id'
-  }, {
-    title: '用户名', dataIndex: 'username'
-  }, {
-    title: '分组', dataIndex: 'group', render: (text, record, index) => {
-      return (<div>
-        {renderGroup(text)}
-      </div>);
-    }
-  }, {
-    title: '统计信息', dataIndex: 'info', render: (text, record, index) => {
-      return (<div>
-        <Space spacing={1}>
-          <Tooltip content={'剩余额度'}>
-            <Tag color="white" size="large">{renderQuota(record.quota)}</Tag>
-          </Tooltip>
-          <Tooltip content={'已用额度'}>
-            <Tag color="white" size="large">{renderQuota(record.used_quota)}</Tag>
-          </Tooltip>
-          <Tooltip content={'调用次数'}>
-            <Tag color="white" size="large">{renderNumber(record.request_count)}</Tag>
-          </Tooltip>
-        </Space>
-      </div>);
-    }
-  }, {
-    title: '邀请信息', dataIndex: 'invite', render: (text, record, index) => {
-      return (<div>
-        <Space spacing={1}>
-          <Tooltip content={'邀请人数'}>
-            <Tag color="white" size="large">{renderNumber(record.aff_count)}</Tag>
-          </Tooltip>
-          <Tooltip content={'邀请总收益'}>
-            <Tag color="white" size="large">{renderQuota(record.aff_history_quota)}</Tag>
-          </Tooltip>
-          <Tooltip content={'邀请人ID'}>
-            {record.inviter_id === 0 ? <Tag color="white" size="large">无</Tag> :
-              <Tag color="white" size="large">{record.inviter_id}</Tag>}
-          </Tooltip>
-        </Space>
-      </div>);
-    }
-  }, {
-    title: '角色', dataIndex: 'role', render: (text, record, index) => {
-      return (<div>
-        {renderRole(text)}
-      </div>);
-    }
-  }, {
-    title: '状态', dataIndex: 'status', render: (text, record, index) => {
-      return (<div>
-        {record.DeletedAt !== null ? <Tag color="red">已注销</Tag> : renderStatus(text)}
-      </div>);
-    }
-  }, {
-    title: '', dataIndex: 'operate', render: (text, record, index) => (<div>
-      {
-        record.DeletedAt !== null ? <></> :
-          <>
-            <Popconfirm
-              title="确定?"
-              okType={'warning'}
-              onConfirm={() => {
-                manageUser(record.username, 'promote', record);
-              }}
-            >
-              <Button theme="light" type="warning" style={{ marginRight: 1 }}>提升</Button>
-            </Popconfirm>
-            <Popconfirm
-              title="确定?"
-              okType={'warning'}
-              onConfirm={() => {
-                manageUser(record.username, 'demote', record);
-              }}
-            >
-              <Button theme="light" type="secondary" style={{ marginRight: 1 }}>降级</Button>
-            </Popconfirm>
-            {record.status === 1 ?
-              <Button theme="light" type="warning" style={{ marginRight: 1 }} onClick={async () => {
-                manageUser(record.username, 'disable', record);
-              }}>禁用</Button> :
-              <Button theme="light" type="secondary" style={{ marginRight: 1 }} onClick={async () => {
-                manageUser(record.username, 'enable', record);
-              }} disabled={record.status === 3}>启用</Button>}
-            <Button theme="light" type="tertiary" style={{ marginRight: 1 }} onClick={() => {
-              setEditingUser(record);
-              setShowEditUser(true);
-            }}>编辑</Button>
-          </>
-
-      }
-      <Popconfirm
-        title="确定是否要删除此用户?"
-        content="硬删除,此修改将不可逆"
-        okType={'danger'}
-        position={'left'}
-        onConfirm={() => {
-          manageUser(record.username, 'delete', record).then(() => {
-            removeRecord(record.id);
-          });
-        }}
-      >
-        <Button theme="light" type="danger" style={{ marginRight: 1 }}>删除</Button>
-      </Popconfirm>
-    </div>)
-  }];
+  const columns = [
+    {
+      title: 'ID',
+      dataIndex: 'id',
+    },
+    {
+      title: '用户名',
+      dataIndex: 'username',
+    },
+    {
+      title: '分组',
+      dataIndex: 'group',
+      render: (text, record, index) => {
+        return <div>{renderGroup(text)}</div>;
+      },
+    },
+    {
+      title: '统计信息',
+      dataIndex: 'info',
+      render: (text, record, index) => {
+        return (
+          <div>
+            <Space spacing={1}>
+              <Tooltip content={'剩余额度'}>
+                <Tag color='white' size='large'>
+                  {renderQuota(record.quota)}
+                </Tag>
+              </Tooltip>
+              <Tooltip content={'已用额度'}>
+                <Tag color='white' size='large'>
+                  {renderQuota(record.used_quota)}
+                </Tag>
+              </Tooltip>
+              <Tooltip content={'调用次数'}>
+                <Tag color='white' size='large'>
+                  {renderNumber(record.request_count)}
+                </Tag>
+              </Tooltip>
+            </Space>
+          </div>
+        );
+      },
+    },
+    {
+      title: '邀请信息',
+      dataIndex: 'invite',
+      render: (text, record, index) => {
+        return (
+          <div>
+            <Space spacing={1}>
+              <Tooltip content={'邀请人数'}>
+                <Tag color='white' size='large'>
+                  {renderNumber(record.aff_count)}
+                </Tag>
+              </Tooltip>
+              <Tooltip content={'邀请总收益'}>
+                <Tag color='white' size='large'>
+                  {renderQuota(record.aff_history_quota)}
+                </Tag>
+              </Tooltip>
+              <Tooltip content={'邀请人ID'}>
+                {record.inviter_id === 0 ? (
+                  <Tag color='white' size='large'>
+                    无
+                  </Tag>
+                ) : (
+                  <Tag color='white' size='large'>
+                    {record.inviter_id}
+                  </Tag>
+                )}
+              </Tooltip>
+            </Space>
+          </div>
+        );
+      },
+    },
+    {
+      title: '角色',
+      dataIndex: 'role',
+      render: (text, record, index) => {
+        return <div>{renderRole(text)}</div>;
+      },
+    },
+    {
+      title: '状态',
+      dataIndex: 'status',
+      render: (text, record, index) => {
+        return (
+          <div>
+            {record.DeletedAt !== null ? (
+              <Tag color='red'>已注销</Tag>
+            ) : (
+              renderStatus(text)
+            )}
+          </div>
+        );
+      },
+    },
+    {
+      title: '',
+      dataIndex: 'operate',
+      render: (text, record, index) => (
+        <div>
+          {record.DeletedAt !== null ? (
+            <></>
+          ) : (
+            <>
+              <Popconfirm
+                title='确定?'
+                okType={'warning'}
+                onConfirm={() => {
+                  manageUser(record.username, 'promote', record);
+                }}
+              >
+                <Button theme='light' type='warning' style={{ marginRight: 1 }}>
+                  提升
+                </Button>
+              </Popconfirm>
+              <Popconfirm
+                title='确定?'
+                okType={'warning'}
+                onConfirm={() => {
+                  manageUser(record.username, 'demote', record);
+                }}
+              >
+                <Button
+                  theme='light'
+                  type='secondary'
+                  style={{ marginRight: 1 }}
+                >
+                  降级
+                </Button>
+              </Popconfirm>
+              {record.status === 1 ? (
+                <Button
+                  theme='light'
+                  type='warning'
+                  style={{ marginRight: 1 }}
+                  onClick={async () => {
+                    manageUser(record.username, 'disable', record);
+                  }}
+                >
+                  禁用
+                </Button>
+              ) : (
+                <Button
+                  theme='light'
+                  type='secondary'
+                  style={{ marginRight: 1 }}
+                  onClick={async () => {
+                    manageUser(record.username, 'enable', record);
+                  }}
+                  disabled={record.status === 3}
+                >
+                  启用
+                </Button>
+              )}
+              <Button
+                theme='light'
+                type='tertiary'
+                style={{ marginRight: 1 }}
+                onClick={() => {
+                  setEditingUser(record);
+                  setShowEditUser(true);
+                }}
+              >
+                编辑
+              </Button>
+            </>
+          )}
+          <Popconfirm
+            title='确定是否要删除此用户?'
+            content='硬删除,此修改将不可逆'
+            okType={'danger'}
+            position={'left'}
+            onConfirm={() => {
+              manageUser(record.username, 'delete', record).then(() => {
+                removeRecord(record.id);
+              });
+            }}
+          >
+            <Button theme='light' type='danger' style={{ marginRight: 1 }}>
+              删除
+            </Button>
+          </Popconfirm>
+        </div>
+      ),
+    },
+  ];
 
   const [users, setUsers] = useState([]);
   const [loading, setLoading] = useState(true);
@@ -137,22 +239,22 @@ const UsersTable = () => {
   const [showAddUser, setShowAddUser] = useState(false);
   const [showEditUser, setShowEditUser] = useState(false);
   const [editingUser, setEditingUser] = useState({
-    id: undefined
+    id: undefined,
   });
 
   const setCount = (data) => {
-    if (data.length >= (activePage) * ITEMS_PER_PAGE) {
+    if (data.length >= activePage * ITEMS_PER_PAGE) {
       setUserCount(data.length + 1);
     } else {
       setUserCount(data.length);
     }
   };
 
-  const removeRecord = key => {
+  const removeRecord = (key) => {
     console.log(key);
     let newDataSource = [...users];
     if (key != null) {
-      let idx = newDataSource.findIndex(data => data.id === key);
+      let idx = newDataSource.findIndex((data) => data.id === key);
 
       if (idx > -1) {
         newDataSource.splice(idx, 1);
@@ -200,7 +302,8 @@ const UsersTable = () => {
 
   const manageUser = async (username, action, record) => {
     const res = await API.post('/api/user/manage', {
-      username, action
+      username,
+      action,
     });
     const { success, message } = res.data;
     if (success) {
@@ -208,7 +311,6 @@ const UsersTable = () => {
       let user = res.data.data;
       let newUsers = [...users];
       if (action === 'delete') {
-
       } else {
         record.status = user.status;
         record.role = user.role;
@@ -222,15 +324,19 @@ const UsersTable = () => {
   const renderStatus = (status) => {
     switch (status) {
       case 1:
-        return <Tag size="large">已激活</Tag>;
+        return <Tag size='large'>已激活</Tag>;
       case 2:
-        return (<Tag size="large" color="red">
-          已封禁
-        </Tag>);
+        return (
+          <Tag size='large' color='red'>
+            已封禁
+          </Tag>
+        );
       default:
-        return (<Tag size="large" color="grey">
-          未知状态
-        </Tag>);
+        return (
+          <Tag size='large' color='grey'>
+            未知状态
+          </Tag>
+        );
     }
   };
 
@@ -271,16 +377,18 @@ const UsersTable = () => {
     setLoading(false);
   };
 
-  const handlePageChange = page => {
+  const handlePageChange = (page) => {
     setActivePage(page);
     if (page === Math.ceil(users.length / ITEMS_PER_PAGE) + 1) {
       // In this case we have to load more data and then append them.
-      loadUsers(page - 1).then(r => {
-      });
+      loadUsers(page - 1).then((r) => {});
     }
   };
 
-  const pageData = users.slice((activePage - 1) * ITEMS_PER_PAGE, activePage * ITEMS_PER_PAGE);
+  const pageData = users.slice(
+    (activePage - 1) * ITEMS_PER_PAGE,
+    activePage * ITEMS_PER_PAGE,
+  );
 
   const closeAddUser = () => {
     setShowAddUser(false);
@@ -289,7 +397,7 @@ const UsersTable = () => {
   const closeEditUser = () => {
     setShowEditUser(false);
     setEditingUser({
-      id: undefined
+      id: undefined,
     });
   };
 
@@ -303,34 +411,52 @@ const UsersTable = () => {
 
   return (
     <>
-      <AddUser refresh={refresh} visible={showAddUser} handleClose={closeAddUser}></AddUser>
-      <EditUser refresh={refresh} visible={showEditUser} handleClose={closeEditUser}
-                editingUser={editingUser}></EditUser>
+      <AddUser
+        refresh={refresh}
+        visible={showAddUser}
+        handleClose={closeAddUser}
+      ></AddUser>
+      <EditUser
+        refresh={refresh}
+        visible={showEditUser}
+        handleClose={closeEditUser}
+        editingUser={editingUser}
+      ></EditUser>
       <Form onSubmit={searchUsers}>
         <Form.Input
-          label="搜索关键字"
-          icon="search"
-          field="keyword"
-          iconPosition="left"
-          placeholder="搜索用户的 ID,用户名,显示名称,以及邮箱地址 ..."
+          label='搜索关键字'
+          icon='search'
+          field='keyword'
+          iconPosition='left'
+          placeholder='搜索用户的 ID,用户名,显示名称,以及邮箱地址 ...'
           value={searchKeyword}
           loading={searching}
-          onChange={value => handleKeywordChange(value)}
+          onChange={(value) => handleKeywordChange(value)}
         />
       </Form>
 
-      <Table columns={columns} dataSource={pageData} pagination={{
-        currentPage: activePage,
-        pageSize: ITEMS_PER_PAGE,
-        total: userCount,
-        pageSizeOpts: [10, 20, 50, 100],
-        onPageChange: handlePageChange
-      }} loading={loading} />
-      <Button theme="light" type="primary" style={{ marginRight: 8 }} onClick={
-        () => {
+      <Table
+        columns={columns}
+        dataSource={pageData}
+        pagination={{
+          currentPage: activePage,
+          pageSize: ITEMS_PER_PAGE,
+          total: userCount,
+          pageSizeOpts: [10, 20, 50, 100],
+          onPageChange: handlePageChange,
+        }}
+        loading={loading}
+      />
+      <Button
+        theme='light'
+        type='primary'
+        style={{ marginRight: 8 }}
+        onClick={() => {
           setShowAddUser(true);
-        }
-      }>添加用户</Button>
+        }}
+      >
+        添加用户
+      </Button>
     </>
   );
 };

+ 21 - 9
web/src/components/WeChatIcon.js

@@ -3,15 +3,27 @@ import { Icon } from '@douyinfe/semi-ui';
 
 const WeChatIcon = () => {
   function CustomIcon() {
-    return <svg t="1709714447384" className="icon" viewBox="0 0 1024 1024" version="1.1"
-                xmlns="http://www.w3.org/2000/svg" p-id="5091" width="16" height="16">
-      <path
-        d="M690.1 377.4c5.9 0 11.8 0.2 17.6 0.5-24.4-128.7-158.3-227.1-319.9-227.1C209 150.8 64 271.4 64 420.2c0 81.1 43.6 154.2 111.9 203.6 5.5 3.9 9.1 10.3 9.1 17.6 0 2.4-0.5 4.6-1.1 6.9-5.5 20.3-14.2 52.8-14.6 54.3-0.7 2.6-1.7 5.2-1.7 7.9 0 5.9 4.8 10.8 10.8 10.8 2.3 0 4.2-0.9 6.2-2l70.9-40.9c5.3-3.1 11-5 17.2-5 3.2 0 6.4 0.5 9.5 1.4 33.1 9.5 68.8 14.8 105.7 14.8 6 0 11.9-0.1 17.8-0.4-7.1-21-10.9-43.1-10.9-66 0-135.8 132.2-245.8 295.3-245.8z m-194.3-86.5c23.8 0 43.2 19.3 43.2 43.1s-19.3 43.1-43.2 43.1c-23.8 0-43.2-19.3-43.2-43.1s19.4-43.1 43.2-43.1z m-215.9 86.2c-23.8 0-43.2-19.3-43.2-43.1s19.3-43.1 43.2-43.1 43.2 19.3 43.2 43.1-19.4 43.1-43.2 43.1z"
-        p-id="5092"></path>
-      <path
-        d="M866.7 792.7c56.9-41.2 93.2-102 93.2-169.7 0-124-120.8-224.5-269.9-224.5-149 0-269.9 100.5-269.9 224.5S540.9 847.5 690 847.5c30.8 0 60.6-4.4 88.1-12.3 2.6-0.8 5.2-1.2 7.9-1.2 5.2 0 9.9 1.6 14.3 4.1l59.1 34c1.7 1 3.3 1.7 5.2 1.7 2.4 0 4.7-0.9 6.4-2.6 1.7-1.7 2.6-4 2.6-6.4 0-2.2-0.9-4.4-1.4-6.6-0.3-1.2-7.6-28.3-12.2-45.3-0.5-1.9-0.9-3.8-0.9-5.7 0.1-5.9 3.1-11.2 7.6-14.5zM600.2 587.2c-19.9 0-36-16.1-36-35.9 0-19.8 16.1-35.9 36-35.9s36 16.1 36 35.9c0 19.8-16.2 35.9-36 35.9z m179.9 0c-19.9 0-36-16.1-36-35.9 0-19.8 16.1-35.9 36-35.9s36 16.1 36 35.9c-0.1 19.8-16.2 35.9-36 35.9z"
-        p-id="5093"></path>
-    </svg>;
+    return (
+      <svg
+        t='1709714447384'
+        className='icon'
+        viewBox='0 0 1024 1024'
+        version='1.1'
+        xmlns='http://www.w3.org/2000/svg'
+        p-id='5091'
+        width='16'
+        height='16'
+      >
+        <path
+          d='M690.1 377.4c5.9 0 11.8 0.2 17.6 0.5-24.4-128.7-158.3-227.1-319.9-227.1C209 150.8 64 271.4 64 420.2c0 81.1 43.6 154.2 111.9 203.6 5.5 3.9 9.1 10.3 9.1 17.6 0 2.4-0.5 4.6-1.1 6.9-5.5 20.3-14.2 52.8-14.6 54.3-0.7 2.6-1.7 5.2-1.7 7.9 0 5.9 4.8 10.8 10.8 10.8 2.3 0 4.2-0.9 6.2-2l70.9-40.9c5.3-3.1 11-5 17.2-5 3.2 0 6.4 0.5 9.5 1.4 33.1 9.5 68.8 14.8 105.7 14.8 6 0 11.9-0.1 17.8-0.4-7.1-21-10.9-43.1-10.9-66 0-135.8 132.2-245.8 295.3-245.8z m-194.3-86.5c23.8 0 43.2 19.3 43.2 43.1s-19.3 43.1-43.2 43.1c-23.8 0-43.2-19.3-43.2-43.1s19.4-43.1 43.2-43.1z m-215.9 86.2c-23.8 0-43.2-19.3-43.2-43.1s19.3-43.1 43.2-43.1 43.2 19.3 43.2 43.1-19.4 43.1-43.2 43.1z'
+          p-id='5092'
+        ></path>
+        <path
+          d='M866.7 792.7c56.9-41.2 93.2-102 93.2-169.7 0-124-120.8-224.5-269.9-224.5-149 0-269.9 100.5-269.9 224.5S540.9 847.5 690 847.5c30.8 0 60.6-4.4 88.1-12.3 2.6-0.8 5.2-1.2 7.9-1.2 5.2 0 9.9 1.6 14.3 4.1l59.1 34c1.7 1 3.3 1.7 5.2 1.7 2.4 0 4.7-0.9 6.4-2.6 1.7-1.7 2.6-4 2.6-6.4 0-2.2-0.9-4.4-1.4-6.6-0.3-1.2-7.6-28.3-12.2-45.3-0.5-1.9-0.9-3.8-0.9-5.7 0.1-5.9 3.1-11.2 7.6-14.5zM600.2 587.2c-19.9 0-36-16.1-36-35.9 0-19.8 16.1-35.9 36-35.9s36 16.1 36 35.9c0 19.8-16.2 35.9-36 35.9z m179.9 0c-19.9 0-36-16.1-36-35.9 0-19.8 16.1-35.9 36-35.9s36 16.1 36 35.9c-0.1 19.8-16.2 35.9-36 35.9z'
+          p-id='5093'
+        ></path>
+      </svg>
+    );
   }
 
   return (

+ 2 - 2
web/src/components/utils.js

@@ -15,6 +15,6 @@ export async function onGitHubOAuthClicked(github_client_id) {
   const state = await getOAuthState();
   if (!state) return;
   window.open(
-    `https://github.com/login/oauth/authorize?client_id=${github_client_id}&state=${state}&scope=user:email`
+    `https://github.com/login/oauth/authorize?client_id=${github_client_id}&state=${state}&scope=user:email`,
   );
-}
+}

+ 98 - 20
web/src/constants/channel.constants.js

@@ -1,22 +1,100 @@
 export const CHANNEL_OPTIONS = [
-    {key: 1, text: 'OpenAI', value: 1, color: 'green', label: 'OpenAI'},
-    {key: 2, text: 'Midjourney Proxy', value: 2, color: 'light-blue', label: 'Midjourney Proxy'},
-    {key: 5, text: 'Midjourney Proxy Plus', value: 5, color: 'blue', label: 'Midjourney Proxy Plus'},
-    {key: 4, text: 'Ollama', value: 4, color: 'grey', label: 'Ollama'},
-    {key: 14, text: 'Anthropic Claude', value: 14, color: 'indigo', label: 'Anthropic Claude'},
-    {key: 3, text: 'Azure OpenAI', value: 3, color: 'teal', label: 'Azure OpenAI'},
-    {key: 11, text: 'Google PaLM2', value: 11, color: 'orange', label: 'Google PaLM2'},
-    {key: 24, text: 'Google Gemini', value: 24, color: 'orange', label: 'Google Gemini'},
-    {key: 15, text: '百度文心千帆', value: 15, color: 'blue', label: '百度文心千帆'},
-    {key: 17, text: '阿里通义千问', value: 17, color: 'orange', label: '阿里通义千问'},
-    {key: 18, text: '讯飞星火认知', value: 18, color: 'blue', label: '讯飞星火认知'},
-    {key: 16, text: '智谱 ChatGLM', value: 16, color: 'violet', label: '智谱 ChatGLM'},
-    {key: 16, text: '智谱 GLM-4V', value: 26, color: 'purple', label: '智谱 GLM-4V'},
-    {key: 16, text: 'Moonshot', value: 25, color: 'green', label: 'Moonshot'},
-    {key: 19, text: '360 智脑', value: 19, color: 'blue', label: '360 智脑'},
-    {key: 23, text: '腾讯混元', value: 23, color: 'teal', label: '腾讯混元'},
-    {key: 31, text: '零一万物', value: 31, color: 'green', label: '零一万物'},
-    {key: 8, text: '自定义渠道', value: 8, color: 'pink', label: '自定义渠道'},
-    {key: 22, text: '知识库:FastGPT', value: 22, color: 'blue', label: '知识库:FastGPT'},
-    {key: 21, text: '知识库:AI Proxy', value: 21, color: 'purple', label: '知识库:AI Proxy'},
+  { key: 1, text: 'OpenAI', value: 1, color: 'green', label: 'OpenAI' },
+  {
+    key: 2,
+    text: 'Midjourney Proxy',
+    value: 2,
+    color: 'light-blue',
+    label: 'Midjourney Proxy',
+  },
+  {
+    key: 5,
+    text: 'Midjourney Proxy Plus',
+    value: 5,
+    color: 'blue',
+    label: 'Midjourney Proxy Plus',
+  },
+  { key: 4, text: 'Ollama', value: 4, color: 'grey', label: 'Ollama' },
+  {
+    key: 14,
+    text: 'Anthropic Claude',
+    value: 14,
+    color: 'indigo',
+    label: 'Anthropic Claude',
+  },
+  {
+    key: 3,
+    text: 'Azure OpenAI',
+    value: 3,
+    color: 'teal',
+    label: 'Azure OpenAI',
+  },
+  {
+    key: 11,
+    text: 'Google PaLM2',
+    value: 11,
+    color: 'orange',
+    label: 'Google PaLM2',
+  },
+  {
+    key: 24,
+    text: 'Google Gemini',
+    value: 24,
+    color: 'orange',
+    label: 'Google Gemini',
+  },
+  {
+    key: 15,
+    text: '百度文心千帆',
+    value: 15,
+    color: 'blue',
+    label: '百度文心千帆',
+  },
+  {
+    key: 17,
+    text: '阿里通义千问',
+    value: 17,
+    color: 'orange',
+    label: '阿里通义千问',
+  },
+  {
+    key: 18,
+    text: '讯飞星火认知',
+    value: 18,
+    color: 'blue',
+    label: '讯飞星火认知',
+  },
+  {
+    key: 16,
+    text: '智谱 ChatGLM',
+    value: 16,
+    color: 'violet',
+    label: '智谱 ChatGLM',
+  },
+  {
+    key: 16,
+    text: '智谱 GLM-4V',
+    value: 26,
+    color: 'purple',
+    label: '智谱 GLM-4V',
+  },
+  { key: 16, text: 'Moonshot', value: 25, color: 'green', label: 'Moonshot' },
+  { key: 19, text: '360 智脑', value: 19, color: 'blue', label: '360 智脑' },
+  { key: 23, text: '腾讯混元', value: 23, color: 'teal', label: '腾讯混元' },
+  { key: 31, text: '零一万物', value: 31, color: 'green', label: '零一万物' },
+  { key: 8, text: '自定义渠道', value: 8, color: 'pink', label: '自定义渠道' },
+  {
+    key: 22,
+    text: '知识库:FastGPT',
+    value: 22,
+    color: 'blue',
+    label: '知识库:FastGPT',
+  },
+  {
+    key: 21,
+    text: '知识库:AI Proxy',
+    value: 21,
+    color: 'purple',
+    label: '知识库:AI Proxy',
+  },
 ];

+ 1 - 1
web/src/constants/index.js

@@ -1,4 +1,4 @@
 export * from './toast.constants';
 export * from './user.constants';
 export * from './common.constant';
-export * from './channel.constants';
+export * from './channel.constants';

+ 1 - 1
web/src/constants/toast.constants.js

@@ -3,5 +3,5 @@ export const toastConstants = {
   INFO_TIMEOUT: 3000,
   ERROR_TIMEOUT: 5000,
   WARNING_TIMEOUT: 10000,
-  NOTICE_TIMEOUT: 20000
+  NOTICE_TIMEOUT: 20000,
 };

+ 14 - 14
web/src/constants/user.constants.js

@@ -1,19 +1,19 @@
 export const userConstants = {
-    REGISTER_REQUEST: 'USERS_REGISTER_REQUEST',
-    REGISTER_SUCCESS: 'USERS_REGISTER_SUCCESS',
-    REGISTER_FAILURE: 'USERS_REGISTER_FAILURE',
+  REGISTER_REQUEST: 'USERS_REGISTER_REQUEST',
+  REGISTER_SUCCESS: 'USERS_REGISTER_SUCCESS',
+  REGISTER_FAILURE: 'USERS_REGISTER_FAILURE',
 
-    LOGIN_REQUEST: 'USERS_LOGIN_REQUEST',
-    LOGIN_SUCCESS: 'USERS_LOGIN_SUCCESS',
-    LOGIN_FAILURE: 'USERS_LOGIN_FAILURE',
-    
-    LOGOUT: 'USERS_LOGOUT',
+  LOGIN_REQUEST: 'USERS_LOGIN_REQUEST',
+  LOGIN_SUCCESS: 'USERS_LOGIN_SUCCESS',
+  LOGIN_FAILURE: 'USERS_LOGIN_FAILURE',
 
-    GETALL_REQUEST: 'USERS_GETALL_REQUEST',
-    GETALL_SUCCESS: 'USERS_GETALL_SUCCESS',
-    GETALL_FAILURE: 'USERS_GETALL_FAILURE',
+  LOGOUT: 'USERS_LOGOUT',
 
-    DELETE_REQUEST: 'USERS_DELETE_REQUEST',
-    DELETE_SUCCESS: 'USERS_DELETE_SUCCESS',
-    DELETE_FAILURE: 'USERS_DELETE_FAILURE'    
+  GETALL_REQUEST: 'USERS_GETALL_REQUEST',
+  GETALL_SUCCESS: 'USERS_GETALL_SUCCESS',
+  GETALL_FAILURE: 'USERS_GETALL_FAILURE',
+
+  DELETE_REQUEST: 'USERS_DELETE_REQUEST',
+  DELETE_SUCCESS: 'USERS_DELETE_SUCCESS',
+  DELETE_FAILURE: 'USERS_DELETE_FAILURE',
 };

+ 1 - 1
web/src/context/Status/index.js

@@ -16,4 +16,4 @@ export const StatusProvider = ({ children }) => {
       {children}
     </StatusContext.Provider>
   );
-};
+};

+ 9 - 9
web/src/context/User/index.js

@@ -1,19 +1,19 @@
 // contexts/User/index.jsx
 
-import React from "react"
-import { reducer, initialState } from "./reducer"
+import React from 'react';
+import { reducer, initialState } from './reducer';
 
 export const UserContext = React.createContext({
   state: initialState,
-  dispatch: () => null
-})
+  dispatch: () => null,
+});
 
 export const UserProvider = ({ children }) => {
-  const [state, dispatch] = React.useReducer(reducer, initialState)
+  const [state, dispatch] = React.useReducer(reducer, initialState);
 
   return (
-    <UserContext.Provider value={[ state, dispatch ]}>
-      { children }
+    <UserContext.Provider value={[state, dispatch]}>
+      {children}
     </UserContext.Provider>
-  )
-}
+  );
+};

+ 4 - 4
web/src/context/User/reducer.js

@@ -3,12 +3,12 @@ export const reducer = (state, action) => {
     case 'login':
       return {
         ...state,
-        user: action.payload
+        user: action.payload,
       };
     case 'logout':
       return {
         ...state,
-        user: undefined
+        user: undefined,
       };
 
     default:
@@ -17,5 +17,5 @@ export const reducer = (state, action) => {
 };
 
 export const initialState = {
-  user: undefined
-};
+  user: undefined,
+};

+ 4 - 2
web/src/helpers/api.js

@@ -2,12 +2,14 @@ import { showError } from './utils';
 import axios from 'axios';
 
 export const API = axios.create({
-  baseURL: import.meta.env.VITE_REACT_APP_SERVER_URL ? import.meta.env.VITE_REACT_APP_SERVER_URL : '',
+  baseURL: import.meta.env.VITE_REACT_APP_SERVER_URL
+    ? import.meta.env.VITE_REACT_APP_SERVER_URL
+    : '',
 });
 
 API.interceptors.response.use(
   (response) => response,
   (error) => {
     showError(error);
-  }
+  },
 );

+ 8 - 8
web/src/helpers/auth-header.js

@@ -1,10 +1,10 @@
 export function authHeader() {
-    // return authorization header with jwt token
-    let user = JSON.parse(localStorage.getItem('user'));
+  // return authorization header with jwt token
+  let user = JSON.parse(localStorage.getItem('user'));
 
-    if (user && user.token) {
-        return { 'Authorization': 'Bearer ' + user.token };
-    } else {
-        return {};
-    }
-}
+  if (user && user.token) {
+    return { Authorization: 'Bearer ' + user.token };
+  } else {
+    return {};
+  }
+}

+ 1 - 1
web/src/helpers/history.js

@@ -1,3 +1,3 @@
 import { createBrowserHistory } from 'history';
 
-export const history = createBrowserHistory();
+export const history = createBrowserHistory();

+ 1 - 1
web/src/helpers/index.js

@@ -1,4 +1,4 @@
 export * from './history';
 export * from './auth-header';
 export * from './utils';
-export * from './api';
+export * from './api';

+ 162 - 135
web/src/helpers/render.js

@@ -1,170 +1,197 @@
-import {Label} from 'semantic-ui-react';
-import {Tag} from "@douyinfe/semi-ui";
+import { Label } from 'semantic-ui-react';
+import { Tag } from '@douyinfe/semi-ui';
 
 export function renderText(text, limit) {
-    if (text.length > limit) {
-        return text.slice(0, limit - 3) + '...';
-    }
-    return text;
+  if (text.length > limit) {
+    return text.slice(0, limit - 3) + '...';
+  }
+  return text;
 }
 
 export function renderGroup(group) {
-    if (group === '') {
-        return <Tag size='large'>default</Tag>;
-    }
-    let groups = group.split(',');
-    groups.sort();
-    return <>
-        {groups.map((group) => {
-            if (group === 'vip' || group === 'pro') {
-                return <Tag size='large' color='yellow'>{group}</Tag>;
-            } else if (group === 'svip' || group === 'premium') {
-                return <Tag size='large' color='red'>{group}</Tag>;
-            }
-            if (group === 'default') {
-                return <Tag size='large'>{group}</Tag>;
-            } else {
-                return <Tag size='large' color={stringToColor(group)}>{group}</Tag>;
-            }
-        })}
-    </>;
+  if (group === '') {
+    return <Tag size='large'>default</Tag>;
+  }
+  let groups = group.split(',');
+  groups.sort();
+  return (
+    <>
+      {groups.map((group) => {
+        if (group === 'vip' || group === 'pro') {
+          return (
+            <Tag size='large' color='yellow'>
+              {group}
+            </Tag>
+          );
+        } else if (group === 'svip' || group === 'premium') {
+          return (
+            <Tag size='large' color='red'>
+              {group}
+            </Tag>
+          );
+        }
+        if (group === 'default') {
+          return <Tag size='large'>{group}</Tag>;
+        } else {
+          return (
+            <Tag size='large' color={stringToColor(group)}>
+              {group}
+            </Tag>
+          );
+        }
+      })}
+    </>
+  );
 }
 
 export function renderNumber(num) {
-    if (num >= 1000000000) {
-        return (num / 1000000000).toFixed(1) + 'B';
-    } else if (num >= 1000000) {
-        return (num / 1000000).toFixed(1) + 'M';
-    } else if (num >= 10000) {
-        return (num / 1000).toFixed(1) + 'k';
-    } else {
-        return num;
-    }
+  if (num >= 1000000000) {
+    return (num / 1000000000).toFixed(1) + 'B';
+  } else if (num >= 1000000) {
+    return (num / 1000000).toFixed(1) + 'M';
+  } else if (num >= 10000) {
+    return (num / 1000).toFixed(1) + 'k';
+  } else {
+    return num;
+  }
 }
 
 export function renderQuotaNumberWithDigit(num, digits = 2) {
-    let displayInCurrency = localStorage.getItem('display_in_currency');
-    num = num.toFixed(digits);
-    if (displayInCurrency) {
-        return '$' + num;
-    }
-    return num;
+  let displayInCurrency = localStorage.getItem('display_in_currency');
+  num = num.toFixed(digits);
+  if (displayInCurrency) {
+    return '$' + num;
+  }
+  return num;
 }
 
 export function renderNumberWithPoint(num) {
-    num = num.toFixed(2);
-    if (num >= 100000) {
-        // Convert number to string to manipulate it
-        let numStr = num.toString();
-        // Find the position of the decimal point
-        let decimalPointIndex = numStr.indexOf('.');
-
-        let wholePart = numStr;
-        let decimalPart = '';
-
-        // If there is a decimal point, split the number into whole and decimal parts
-        if (decimalPointIndex !== -1) {
-            wholePart = numStr.slice(0, decimalPointIndex);
-            decimalPart = numStr.slice(decimalPointIndex);
-        }
+  num = num.toFixed(2);
+  if (num >= 100000) {
+    // Convert number to string to manipulate it
+    let numStr = num.toString();
+    // Find the position of the decimal point
+    let decimalPointIndex = numStr.indexOf('.');
+
+    let wholePart = numStr;
+    let decimalPart = '';
+
+    // If there is a decimal point, split the number into whole and decimal parts
+    if (decimalPointIndex !== -1) {
+      wholePart = numStr.slice(0, decimalPointIndex);
+      decimalPart = numStr.slice(decimalPointIndex);
+    }
 
-        // Take the first two and last two digits of the whole number part
-        let shortenedWholePart = wholePart.slice(0, 2) + '..' + wholePart.slice(-2);
+    // Take the first two and last two digits of the whole number part
+    let shortenedWholePart = wholePart.slice(0, 2) + '..' + wholePart.slice(-2);
 
-        // Return the formatted number
-        return shortenedWholePart + decimalPart;
-    }
+    // Return the formatted number
+    return shortenedWholePart + decimalPart;
+  }
 
-    // If the number is less than 100,000, return it unmodified
-    return num;
+  // If the number is less than 100,000, return it unmodified
+  return num;
 }
 
 export function getQuotaPerUnit() {
-    let quotaPerUnit = localStorage.getItem('quota_per_unit');
-    quotaPerUnit = parseFloat(quotaPerUnit);
-    return quotaPerUnit;
+  let quotaPerUnit = localStorage.getItem('quota_per_unit');
+  quotaPerUnit = parseFloat(quotaPerUnit);
+  return quotaPerUnit;
 }
 
 export function getQuotaWithUnit(quota, digits = 6) {
-    let quotaPerUnit = localStorage.getItem('quota_per_unit');
-    quotaPerUnit = parseFloat(quotaPerUnit);
-    return (quota / quotaPerUnit).toFixed(digits);
+  let quotaPerUnit = localStorage.getItem('quota_per_unit');
+  quotaPerUnit = parseFloat(quotaPerUnit);
+  return (quota / quotaPerUnit).toFixed(digits);
 }
 
 export function renderQuota(quota, digits = 2) {
-    let quotaPerUnit = localStorage.getItem('quota_per_unit');
-    let displayInCurrency = localStorage.getItem('display_in_currency');
-    quotaPerUnit = parseFloat(quotaPerUnit);
-    displayInCurrency = displayInCurrency === 'true';
-    if (displayInCurrency) {
-        return '$' + (quota / quotaPerUnit).toFixed(digits);
-    }
-    return renderNumber(quota);
+  let quotaPerUnit = localStorage.getItem('quota_per_unit');
+  let displayInCurrency = localStorage.getItem('display_in_currency');
+  quotaPerUnit = parseFloat(quotaPerUnit);
+  displayInCurrency = displayInCurrency === 'true';
+  if (displayInCurrency) {
+    return '$' + (quota / quotaPerUnit).toFixed(digits);
+  }
+  return renderNumber(quota);
 }
 
 export function renderQuotaWithPrompt(quota, digits) {
-    let displayInCurrency = localStorage.getItem('display_in_currency');
-    displayInCurrency = displayInCurrency === 'true';
-    if (displayInCurrency) {
-        return `(等价金额:${renderQuota(quota, digits)})`;
-    }
-    return '';
+  let displayInCurrency = localStorage.getItem('display_in_currency');
+  displayInCurrency = displayInCurrency === 'true';
+  if (displayInCurrency) {
+    return `(等价金额:${renderQuota(quota, digits)})`;
+  }
+  return '';
 }
 
-const colors = ['amber', 'blue', 'cyan', 'green', 'grey', 'indigo',
-    'light-blue', 'lime', 'orange', 'pink',
-    'purple', 'red', 'teal', 'violet', 'yellow'
-]
+const colors = [
+  'amber',
+  'blue',
+  'cyan',
+  'green',
+  'grey',
+  'indigo',
+  'light-blue',
+  'lime',
+  'orange',
+  'pink',
+  'purple',
+  'red',
+  'teal',
+  'violet',
+  'yellow',
+];
 
 export const modelColorMap = {
-    'dall-e': 'rgb(147,112,219)',  // 深紫色
-    'dall-e-2': 'rgb(147,112,219)',  // 介于紫色和蓝色之间的色调
-    'dall-e-3': 'rgb(153,50,204)',  // 介于紫罗兰和洋红之间的色调
-    'midjourney': 'rgb(136,43,180)',  // 介于紫罗兰和洋红之间的色调
-    'gpt-3.5-turbo': 'rgb(184,227,167)',  // 浅绿色
-    'gpt-3.5-turbo-0301': 'rgb(131,220,131)',  // 亮绿色
-    'gpt-3.5-turbo-0613': 'rgb(60,179,113)',  // 海洋绿
-    'gpt-3.5-turbo-1106': 'rgb(32,178,170)',  // 浅海洋绿
-    'gpt-3.5-turbo-16k': 'rgb(252,200,149)',  // 淡橙色
-    'gpt-3.5-turbo-16k-0613': 'rgb(255,181,119)',  // 淡桃色
-    'gpt-3.5-turbo-instruct': 'rgb(175,238,238)',  // 粉蓝色
-    'gpt-4': 'rgb(135,206,235)',  // 天蓝色
-    'gpt-4-0314': 'rgb(70,130,180)',  // 钢蓝色
-    'gpt-4-0613': 'rgb(100,149,237)',  // 矢车菊蓝
-    'gpt-4-1106-preview': 'rgb(30,144,255)',  // 道奇蓝
-    'gpt-4-0125-preview': 'rgb(2,177,236)',  // 深天蓝
-    'gpt-4-turbo-preview': 'rgb(2,177,255)',  // 深天蓝
-    'gpt-4-32k': 'rgb(104,111,238)',  // 中紫色
-    'gpt-4-32k-0314': 'rgb(90,105,205)',  // 暗灰蓝色
-    'gpt-4-32k-0613': 'rgb(61,71,139)',  // 暗蓝灰色
-    'gpt-4-all': 'rgb(65,105,225)',  // 皇家蓝
-    'gpt-4-gizmo-*': 'rgb(0,0,255)',  // 纯蓝色
-    'gpt-4-vision-preview': 'rgb(25,25,112)',  // 午夜蓝
-    'text-ada-001': 'rgb(255,192,203)',  // 粉红色
-    'text-babbage-001': 'rgb(255,160,122)',  // 浅珊瑚色
-    'text-curie-001': 'rgb(219,112,147)',  // 苍紫罗兰色
-    'text-davinci-002': 'rgb(199,21,133)',  // 中紫罗兰红色
-    'text-davinci-003': 'rgb(219,112,147)',  // 苍紫罗兰色(与Curie相同,表示同一个系列)
-    'text-davinci-edit-001': 'rgb(255,105,180)',  // 热粉色
-    'text-embedding-ada-002': 'rgb(255,182,193)',  // 浅粉红
-    'text-embedding-v1': 'rgb(255,174,185)',  // 浅粉红色(略有区别)
-    'text-moderation-latest': 'rgb(255,130,171)',  // 强粉色
-    'text-moderation-stable': 'rgb(255,160,122)',  // 浅珊瑚色(与Babbage相同,表示同一类功能)
-    'tts-1': 'rgb(255,140,0)',  // 深橙色
-    'tts-1-1106': 'rgb(255,165,0)',  // 橙色
-    'tts-1-hd': 'rgb(255,215,0)',  // 金色
-    'tts-1-hd-1106': 'rgb(255,223,0)',  // 金黄色(略有区别)
-    'whisper-1': 'rgb(245,245,220)'  // 米色
-}
+  'dall-e': 'rgb(147,112,219)', // 深紫色
+  'dall-e-2': 'rgb(147,112,219)', // 介于紫色和蓝色之间的色调
+  'dall-e-3': 'rgb(153,50,204)', // 介于紫罗兰和洋红之间的色调
+  midjourney: 'rgb(136,43,180)', // 介于紫罗兰和洋红之间的色调
+  'gpt-3.5-turbo': 'rgb(184,227,167)', // 浅绿色
+  'gpt-3.5-turbo-0301': 'rgb(131,220,131)', // 亮绿色
+  'gpt-3.5-turbo-0613': 'rgb(60,179,113)', // 海洋绿
+  'gpt-3.5-turbo-1106': 'rgb(32,178,170)', // 浅海洋绿
+  'gpt-3.5-turbo-16k': 'rgb(252,200,149)', // 淡橙色
+  'gpt-3.5-turbo-16k-0613': 'rgb(255,181,119)', // 淡桃色
+  'gpt-3.5-turbo-instruct': 'rgb(175,238,238)', // 粉蓝色
+  'gpt-4': 'rgb(135,206,235)', // 天蓝色
+  'gpt-4-0314': 'rgb(70,130,180)', // 钢蓝色
+  'gpt-4-0613': 'rgb(100,149,237)', // 矢车菊蓝
+  'gpt-4-1106-preview': 'rgb(30,144,255)', // 道奇蓝
+  'gpt-4-0125-preview': 'rgb(2,177,236)', // 深天蓝
+  'gpt-4-turbo-preview': 'rgb(2,177,255)', // 深天蓝
+  'gpt-4-32k': 'rgb(104,111,238)', // 中紫色
+  'gpt-4-32k-0314': 'rgb(90,105,205)', // 暗灰蓝色
+  'gpt-4-32k-0613': 'rgb(61,71,139)', // 暗蓝灰色
+  'gpt-4-all': 'rgb(65,105,225)', // 皇家蓝
+  'gpt-4-gizmo-*': 'rgb(0,0,255)', // 纯蓝色
+  'gpt-4-vision-preview': 'rgb(25,25,112)', // 午夜蓝
+  'text-ada-001': 'rgb(255,192,203)', // 粉红色
+  'text-babbage-001': 'rgb(255,160,122)', // 浅珊瑚色
+  'text-curie-001': 'rgb(219,112,147)', // 苍紫罗兰色
+  'text-davinci-002': 'rgb(199,21,133)', // 中紫罗兰红色
+  'text-davinci-003': 'rgb(219,112,147)', // 苍紫罗兰色(与Curie相同,表示同一个系列)
+  'text-davinci-edit-001': 'rgb(255,105,180)', // 热粉色
+  'text-embedding-ada-002': 'rgb(255,182,193)', // 浅粉红
+  'text-embedding-v1': 'rgb(255,174,185)', // 浅粉红色(略有区别)
+  'text-moderation-latest': 'rgb(255,130,171)', // 强粉色
+  'text-moderation-stable': 'rgb(255,160,122)', // 浅珊瑚色(与Babbage相同,表示同一类功能)
+  'tts-1': 'rgb(255,140,0)', // 深橙色
+  'tts-1-1106': 'rgb(255,165,0)', // 橙色
+  'tts-1-hd': 'rgb(255,215,0)', // 金色
+  'tts-1-hd-1106': 'rgb(255,223,0)', // 金黄色(略有区别)
+  'whisper-1': 'rgb(245,245,220)', // 米色
+};
 
 export function stringToColor(str) {
-    let sum = 0;
-    // 对字符串中的每个字符进行操作
-    for (let i = 0; i < str.length; i++) {
-        // 将字符的ASCII值加到sum中
-        sum += str.charCodeAt(i);
-    }
-    // 使用模运算得到个位数
-    let i = sum % colors.length;
-    return colors[i];
-}
+  let sum = 0;
+  // 对字符串中的每个字符进行操作
+  for (let i = 0; i < str.length; i++) {
+    // 将字符的ASCII值加到sum中
+    sum += str.charCodeAt(i);
+  }
+  // 使用模运算得到个位数
+  let i = sum % colors.length;
+  return colors[i];
+}

+ 9 - 20
web/src/helpers/utils.js

@@ -1,7 +1,7 @@
 import { Toast } from '@douyinfe/semi-ui';
 import { toastConstants } from '../constants';
 import React from 'react';
-import {toast} from "react-toastify";
+import { toast } from 'react-toastify';
 
 const HTMLToastContent = ({ htmlContent }) => {
   return <div dangerouslySetInnerHTML={{ __html: htmlContent }} />;
@@ -30,7 +30,7 @@ export function getSystemName() {
 export function getLogo() {
   let logo = localStorage.getItem('logo');
   if (!logo) return '/logo.png';
-  return logo
+  return logo;
 }
 
 export function getFooterHTML() {
@@ -157,17 +157,7 @@ export function timestamp2string(timestamp) {
     second = '0' + second;
   }
   return (
-    year +
-    '-' +
-    month +
-    '-' +
-    day +
-    ' ' +
-    hour +
-    ':' +
-    minute +
-    ':' +
-    second
+    year + '-' + month + '-' + day + ' ' + hour + ':' + minute + ':' + second
   );
 }
 
@@ -186,20 +176,20 @@ export function timestamp2string1(timestamp, dataExportDefaultTime = 'hour') {
   if (hour.length === 1) {
     hour = '0' + hour;
   }
-  let str = month + '-' + day
+  let str = month + '-' + day;
   if (dataExportDefaultTime === 'hour') {
-    str += ' ' + hour + ":00"
+    str += ' ' + hour + ':00';
   } else if (dataExportDefaultTime === 'week') {
     let nextWeek = new Date(timestamp * 1000 + 6 * 24 * 60 * 60 * 1000);
     let nextMonth = (nextWeek.getMonth() + 1).toString();
     let nextDay = nextWeek.getDate().toString();
     if (nextMonth.length === 1) {
-        nextMonth = '0' + nextMonth;
+      nextMonth = '0' + nextMonth;
     }
     if (nextDay.length === 1) {
-        nextDay = '0' + nextDay;
+      nextDay = '0' + nextDay;
     }
-    str += ' - ' + nextMonth + '-' + nextDay
+    str += ' - ' + nextMonth + '-' + nextDay;
   }
   return str;
 }
@@ -225,9 +215,8 @@ export const verifyJSON = (str) => {
 export function shouldShowPrompt(id) {
   let prompt = localStorage.getItem(`prompt-${id}`);
   return !prompt;
-
 }
 
 export function setPromptShown(id) {
   localStorage.setItem(`prompt-${id}`, 'true');
-}
+}

+ 59 - 55
web/src/index.css

@@ -1,105 +1,109 @@
 body {
-    margin: 0;
-    padding-top: 55px;
-    overflow-y: scroll;
-    font-family: Lato, 'Helvetica Neue', Arial, Helvetica, "Microsoft YaHei", sans-serif;
-    -webkit-font-smoothing: antialiased;
-    -moz-osx-font-smoothing: grayscale;
-    scrollbar-width: none;
-    color: var(--semi-color-text-0) !important;
-    background-color: var( --semi-color-bg-0) !important;
-    height: 100%;
+  margin: 0;
+  padding-top: 55px;
+  overflow-y: scroll;
+  font-family: Lato, 'Helvetica Neue', Arial, Helvetica, 'Microsoft YaHei',
+    sans-serif;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+  scrollbar-width: none;
+  color: var(--semi-color-text-0) !important;
+  background-color: var(--semi-color-bg-0) !important;
+  height: 100%;
 }
 
 #root {
-    height: 100%;
+  height: 100%;
 }
 
 @media only screen and (max-width: 767px) {
-    .semi-table-tbody, .semi-table-row, .semi-table-row-cell {
-        display: block!important;
-        width: auto!important;
-        padding: 2px!important;
-    }
-    .semi-table-row-cell {
-        border-bottom: 0!important;
-    }
-    .semi-table-tbody>.semi-table-row {
-        border-bottom: 1px solid rgba(0,0,0,.1);
-    }
-    .semi-space {
-        /*display: block!important;*/
-        display: flex;
-        flex-direction: row;
-        flex-wrap: wrap;
-        row-gap: 3px;
-        column-gap: 10px;
-    }
+  .semi-table-tbody,
+  .semi-table-row,
+  .semi-table-row-cell {
+    display: block !important;
+    width: auto !important;
+    padding: 2px !important;
+  }
+  .semi-table-row-cell {
+    border-bottom: 0 !important;
+  }
+  .semi-table-tbody > .semi-table-row {
+    border-bottom: 1px solid rgba(0, 0, 0, 0.1);
+  }
+  .semi-space {
+    /*display: block!important;*/
+    display: flex;
+    flex-direction: row;
+    flex-wrap: wrap;
+    row-gap: 3px;
+    column-gap: 10px;
+  }
 }
 
 .semi-table-tbody > .semi-table-row > .semi-table-row-cell {
-    padding: 16px 14px;
+  padding: 16px 14px;
 }
 
 .channel-table {
-    .semi-table-tbody > .semi-table-row > .semi-table-row-cell {
-        padding: 16px 8px;
-    }
+  .semi-table-tbody > .semi-table-row > .semi-table-row-cell {
+    padding: 16px 8px;
+  }
 }
 
 .semi-layout {
-    height: 100%;
+  height: 100%;
 }
 
 .tableShow {
-    display: revert;
+  display: revert;
 }
 
 .tableHiddle {
-    display: none !important;
+  display: none !important;
 }
 
 body::-webkit-scrollbar {
-    display: none;
+  display: none;
 }
 
 code {
-    font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
+  font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
+    monospace;
 }
 
 .semi-navigation-vertical {
-    /*display: flex;*/
-    /*flex-direction: column;*/
+  /*display: flex;*/
+  /*flex-direction: column;*/
 }
 
 .semi-navigation-item {
-    margin-bottom: 0;
+  margin-bottom: 0;
 }
 
 .semi-navigation-vertical {
-    /*flex: 0 0 auto;*/
-    /*display: flex;*/
-    /*flex-direction: column;*/
-    /*width: 100%;*/
-    height: 100%;
-    overflow: hidden;
+  /*flex: 0 0 auto;*/
+  /*display: flex;*/
+  /*flex-direction: column;*/
+  /*width: 100%;*/
+  height: 100%;
+  overflow: hidden;
 }
 
 .main-content {
-    padding: 4px;
-    height: 100%;
+  padding: 4px;
+  height: 100%;
 }
 
 .small-icon .icon {
-    font-size: 1em !important;
+  font-size: 1em !important;
 }
 
 .custom-footer {
-    font-size: 1.1em;
+  font-size: 1.1em;
 }
 
 @media only screen and (max-width: 600px) {
-    .hide-on-mobile {
-        display: none !important;
-    }
+  .hide-on-mobile {
+    display: none !important;
+  }
 }

+ 36 - 37
web/src/index.js

@@ -1,51 +1,50 @@
 import React from 'react';
 import ReactDOM from 'react-dom/client';
-import {BrowserRouter} from 'react-router-dom';
+import { BrowserRouter } from 'react-router-dom';
 import App from './App';
 import HeaderBar from './components/HeaderBar';
 import Footer from './components/Footer';
 import 'semantic-ui-offline/semantic.min.css';
 import './index.css';
-import {UserProvider} from './context/User';
-import {ToastContainer} from 'react-toastify';
+import { UserProvider } from './context/User';
+import { ToastContainer } from 'react-toastify';
 import 'react-toastify/dist/ReactToastify.css';
-import {StatusProvider} from './context/Status';
-import {Layout} from "@douyinfe/semi-ui";
-import SiderBar from "./components/SiderBar";
+import { StatusProvider } from './context/Status';
+import { Layout } from '@douyinfe/semi-ui';
+import SiderBar from './components/SiderBar';
 
 // initialization
 
-
 const root = ReactDOM.createRoot(document.getElementById('root'));
-const {Sider, Content, Header} = Layout;
+const { Sider, Content, Header } = Layout;
 root.render(
-    <React.StrictMode>
-        <StatusProvider>
-            <UserProvider>
-                <BrowserRouter>
-                    <Layout>
-                        <Sider>
-                            <SiderBar/>
-                        </Sider>
-                        <Layout>
-                            <Header>
-                                <HeaderBar/>
-                            </Header>
-                            <Content
-                                style={{
-                                    padding: '24px',
-                                }}
-                            >
-                                <App/>
-                            </Content>
-                            <Layout.Footer>
-                                <Footer></Footer>
-                            </Layout.Footer>
-                        </Layout>
-                        <ToastContainer/>
-                    </Layout>
-                </BrowserRouter>
-            </UserProvider>
-        </StatusProvider>
-    </React.StrictMode>
+  <React.StrictMode>
+    <StatusProvider>
+      <UserProvider>
+        <BrowserRouter>
+          <Layout>
+            <Sider>
+              <SiderBar />
+            </Sider>
+            <Layout>
+              <Header>
+                <HeaderBar />
+              </Header>
+              <Content
+                style={{
+                  padding: '24px',
+                }}
+              >
+                <App />
+              </Content>
+              <Layout.Footer>
+                <Footer></Footer>
+              </Layout.Footer>
+            </Layout>
+            <ToastContainer />
+          </Layout>
+        </BrowserRouter>
+      </UserProvider>
+    </StatusProvider>
+  </React.StrictMode>,
 );

+ 19 - 14
web/src/pages/About/index.js

@@ -1,7 +1,7 @@
 import React, { useEffect, useState } from 'react';
 import { API, showError } from '../../helpers';
 import { marked } from 'marked';
-import {Layout} from "@douyinfe/semi-ui";
+import { Layout } from '@douyinfe/semi-ui';
 
 const About = () => {
   const [about, setAbout] = useState('');
@@ -31,37 +31,42 @@ const About = () => {
 
   return (
     <>
-      {
-        aboutLoaded && about === '' ? <>
+      {aboutLoaded && about === '' ? (
+        <>
           <Layout>
             <Layout.Header>
               <h3>关于</h3>
             </Layout.Header>
             <Layout.Content>
-              <p>
-                可在设置页面设置关于内容,支持 HTML & Markdown
-              </p>
+              <p>可在设置页面设置关于内容,支持 HTML & Markdown</p>
               new-api项目仓库地址:
               <a href='https://github.com/Calcium-Ion/new-api'>
                 https://github.com/Calcium-Ion/new-api
               </a>
               <p>
-                NewAPI © 2023 CalciumIon | 基于 One API v0.5.4 © 2023 JustSong。本项目根据MIT许可证授权。
+                NewAPI © 2023 CalciumIon | 基于 One API v0.5.4 © 2023
+                JustSong。本项目根据MIT许可证授权。
               </p>
             </Layout.Content>
           </Layout>
-        </> : <>
-          {
-            about.startsWith('https://') ? <iframe
+        </>
+      ) : (
+        <>
+          {about.startsWith('https://') ? (
+            <iframe
               src={about}
               style={{ width: '100%', height: '100vh', border: 'none' }}
-            /> : <div style={{ fontSize: 'larger' }} dangerouslySetInnerHTML={{ __html: about }}></div>
-          }
+            />
+          ) : (
+            <div
+              style={{ fontSize: 'larger' }}
+              dangerouslySetInnerHTML={{ __html: about }}
+            ></div>
+          )}
         </>
-      }
+      )}
     </>
   );
 };
 
-
 export default About;

+ 708 - 606
web/src/pages/Channel/EditChannel.js

@@ -1,631 +1,733 @@
-import React, {useEffect, useRef, useState} from 'react';
-import {useNavigate, useParams} from 'react-router-dom';
-import {API, isMobile, showError, showInfo, showSuccess, verifyJSON} from '../../helpers';
-import {CHANNEL_OPTIONS} from '../../constants';
-import Title from "@douyinfe/semi-ui/lib/es/typography/title";
-import {SideSheet, Space, Spin, Button, Input, Typography, Select, TextArea, Checkbox, Banner} from "@douyinfe/semi-ui";
+import React, { useEffect, useRef, useState } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
+import {
+  API,
+  isMobile,
+  showError,
+  showInfo,
+  showSuccess,
+  verifyJSON,
+} from '../../helpers';
+import { CHANNEL_OPTIONS } from '../../constants';
+import Title from '@douyinfe/semi-ui/lib/es/typography/title';
+import {
+  SideSheet,
+  Space,
+  Spin,
+  Button,
+  Input,
+  Typography,
+  Select,
+  TextArea,
+  Checkbox,
+  Banner,
+} from '@douyinfe/semi-ui';
 
 const MODEL_MAPPING_EXAMPLE = {
-    'gpt-3.5-turbo-0301': 'gpt-3.5-turbo',
-    'gpt-4-0314': 'gpt-4',
-    'gpt-4-32k-0314': 'gpt-4-32k'
+  'gpt-3.5-turbo-0301': 'gpt-3.5-turbo',
+  'gpt-4-0314': 'gpt-4',
+  'gpt-4-32k-0314': 'gpt-4-32k',
 };
 
 function type2secretPrompt(type) {
-    // inputs.type === 15 ? '按照如下格式输入:APIKey|SecretKey' : (inputs.type === 18 ? '按照如下格式输入:APPID|APISecret|APIKey' : '请输入渠道对应的鉴权密钥')
-    switch (type) {
+  // inputs.type === 15 ? '按照如下格式输入:APIKey|SecretKey' : (inputs.type === 18 ? '按照如下格式输入:APPID|APISecret|APIKey' : '请输入渠道对应的鉴权密钥')
+  switch (type) {
+    case 15:
+      return '按照如下格式输入:APIKey|SecretKey';
+    case 18:
+      return '按照如下格式输入:APPID|APISecret|APIKey';
+    case 22:
+      return '按照如下格式输入:APIKey-AppId,例如:fastgpt-0sp2gtvfdgyi4k30jwlgwf1i-64f335d84283f05518e9e041';
+    case 23:
+      return '按照如下格式输入:AppId|SecretId|SecretKey';
+    default:
+      return '请输入渠道对应的鉴权密钥';
+  }
+}
+
+const EditChannel = (props) => {
+  const navigate = useNavigate();
+  const channelId = props.editingChannel.id;
+  const isEdit = channelId !== undefined;
+  const [loading, setLoading] = useState(isEdit);
+  const handleCancel = () => {
+    props.handleClose();
+  };
+  const originInputs = {
+    name: '',
+    type: 1,
+    key: '',
+    openai_organization: '',
+    base_url: '',
+    other: '',
+    model_mapping: '',
+    models: [],
+    auto_ban: 1,
+    groups: ['default'],
+  };
+  const [batch, setBatch] = useState(false);
+  const [autoBan, setAutoBan] = useState(true);
+  // const [autoBan, setAutoBan] = useState(true);
+  const [inputs, setInputs] = useState(originInputs);
+  const [originModelOptions, setOriginModelOptions] = useState([]);
+  const [modelOptions, setModelOptions] = useState([]);
+  const [groupOptions, setGroupOptions] = useState([]);
+  const [basicModels, setBasicModels] = useState([]);
+  const [fullModels, setFullModels] = useState([]);
+  const [customModel, setCustomModel] = useState('');
+  const handleInputChange = (name, value) => {
+    setInputs((inputs) => ({ ...inputs, [name]: value }));
+    if (name === 'type' && inputs.models.length === 0) {
+      let localModels = [];
+      switch (value) {
+        case 14:
+          localModels = [
+            'claude-instant-1.2',
+            'claude-2',
+            'claude-2.0',
+            'claude-2.1',
+            'claude-3-opus-20240229',
+            'claude-3-sonnet-20240229',
+            'claude-3-haiku-20240307',
+          ];
+          break;
+        case 11:
+          localModels = ['PaLM-2'];
+          break;
         case 15:
-            return '按照如下格式输入:APIKey|SecretKey';
+          localModels = [
+            'ERNIE-Bot',
+            'ERNIE-Bot-turbo',
+            'ERNIE-Bot-4',
+            'Embedding-V1',
+          ];
+          break;
+        case 17:
+          localModels = [
+            'qwen-turbo',
+            'qwen-plus',
+            'qwen-max',
+            'qwen-max-longcontext',
+            'text-embedding-v1',
+          ];
+          break;
+        case 16:
+          localModels = ['chatglm_pro', 'chatglm_std', 'chatglm_lite'];
+          break;
         case 18:
-            return '按照如下格式输入:APPID|APISecret|APIKey';
-        case 22:
-            return '按照如下格式输入:APIKey-AppId,例如:fastgpt-0sp2gtvfdgyi4k30jwlgwf1i-64f335d84283f05518e9e041';
+          localModels = [
+            'SparkDesk',
+            'SparkDesk-v1.1',
+            'SparkDesk-v2.1',
+            'SparkDesk-v3.1',
+            'SparkDesk-v3.5',
+          ];
+          break;
+        case 19:
+          localModels = [
+            '360GPT_S2_V9',
+            'embedding-bert-512-v1',
+            'embedding_s1_v1',
+            'semantic_similarity_s1_v1',
+          ];
+          break;
         case 23:
-            return '按照如下格式输入:AppId|SecretId|SecretKey';
-        default:
-            return '请输入渠道对应的鉴权密钥';
+          localModels = ['hunyuan'];
+          break;
+        case 24:
+          localModels = ['gemini-pro', 'gemini-pro-vision'];
+          break;
+        case 25:
+          localModels = [
+            'moonshot-v1-8k',
+            'moonshot-v1-32k',
+            'moonshot-v1-128k',
+          ];
+          break;
+        case 26:
+          localModels = ['glm-4', 'glm-4v', 'glm-3-turbo'];
+          break;
+        case 31:
+          localModels = ['yi-34b-chat-0205', 'yi-34b-chat-200k', 'yi-vl-plus'];
+          break;
+        case 2:
+          localModels = [
+            'mj_imagine',
+            'mj_variation',
+            'mj_reroll',
+            'mj_blend',
+            'mj_upscale',
+            'mj_describe',
+          ];
+          break;
+        case 5:
+          localModels = [
+            'swap_face',
+            'mj_imagine',
+            'mj_variation',
+            'mj_reroll',
+            'mj_blend',
+            'mj_upscale',
+            'mj_describe',
+            'mj_zoom',
+            'mj_shorten',
+            'mj_modal',
+            'mj_inpaint',
+            'mj_custom_zoom',
+            'mj_high_variation',
+            'mj_low_variation',
+            'mj_pan',
+          ];
+          break;
+      }
+      setInputs((inputs) => ({ ...inputs, models: localModels }));
     }
-}
+    //setAutoBan
+  };
 
-const EditChannel = (props) => {
-    const navigate = useNavigate();
-    const channelId = props.editingChannel.id;
-    const isEdit = channelId !== undefined;
-    const [loading, setLoading] = useState(isEdit);
-    const handleCancel = () => {
-        props.handleClose()
-    };
-    const originInputs = {
-        name: '',
-        type: 1,
-        key: '',
-        openai_organization: '',
-        base_url: '',
-        other: '',
-        model_mapping: '',
-        models: [],
-        auto_ban: 1,
-        groups: ['default']
-    };
-    const [batch, setBatch] = useState(false);
-    const [autoBan, setAutoBan] = useState(true);
-    // const [autoBan, setAutoBan] = useState(true);
-    const [inputs, setInputs] = useState(originInputs);
-    const [originModelOptions, setOriginModelOptions] = useState([]);
-    const [modelOptions, setModelOptions] = useState([]);
-    const [groupOptions, setGroupOptions] = useState([]);
-    const [basicModels, setBasicModels] = useState([]);
-    const [fullModels, setFullModels] = useState([]);
-    const [customModel, setCustomModel] = useState('');
-    const handleInputChange = (name, value) => {
-        setInputs((inputs) => ({...inputs, [name]: value}));
-        if (name === 'type' && inputs.models.length === 0) {
-            let localModels = [];
-            switch (value) {
-                case 14:
-                    localModels = ["claude-instant-1.2", "claude-2", "claude-2.0", "claude-2.1", "claude-3-opus-20240229", "claude-3-sonnet-20240229", "claude-3-haiku-20240307"];
-                    break;
-                case 11:
-                    localModels = ['PaLM-2'];
-                    break;
-                case 15:
-                    localModels = ['ERNIE-Bot', 'ERNIE-Bot-turbo', 'ERNIE-Bot-4', 'Embedding-V1'];
-                    break;
-                case 17:
-                    localModels = ["qwen-turbo", "qwen-plus", "qwen-max", "qwen-max-longcontext", 'text-embedding-v1'];
-                    break;
-                case 16:
-                    localModels = ['chatglm_pro', 'chatglm_std', 'chatglm_lite'];
-                    break;
-                case 18:
-                    localModels = ['SparkDesk', 'SparkDesk-v1.1', 'SparkDesk-v2.1', 'SparkDesk-v3.1', 'SparkDesk-v3.5'];
-                    break;
-                case 19:
-                    localModels = ['360GPT_S2_V9', 'embedding-bert-512-v1', 'embedding_s1_v1', 'semantic_similarity_s1_v1'];
-                    break;
-                case 23:
-                    localModels = ['hunyuan'];
-                    break;
-                case 24:
-                    localModels = ['gemini-pro', 'gemini-pro-vision'];
-                    break;
-                case 25:
-                    localModels = ['moonshot-v1-8k', 'moonshot-v1-32k', 'moonshot-v1-128k'];
-                    break;
-                case 26:
-                    localModels = ['glm-4', 'glm-4v', 'glm-3-turbo'];
-                    break;
-                case 31:
-                    localModels = ['yi-34b-chat-0205', 'yi-34b-chat-200k', 'yi-vl-plus'];
-                    break;
-                case 2:
-                    localModels = ['mj_imagine', 'mj_variation', 'mj_reroll', 'mj_blend', 'mj_upscale', 'mj_describe'];
-                    break;
-                case 5:
-                    localModels = [
-                        'swap_face',
-                        'mj_imagine',
-                        'mj_variation',
-                        'mj_reroll',
-                        'mj_blend',
-                        'mj_upscale',
-                        'mj_describe',
-                        'mj_zoom',
-                        'mj_shorten',
-                        'mj_modal',
-                        'mj_inpaint',
-                        'mj_custom_zoom',
-                        'mj_high_variation',
-                        'mj_low_variation',
-                        'mj_pan',
-                    ];
-                    break;
-            }
-            setInputs((inputs) => ({...inputs, models: localModels}));
-        }
-        //setAutoBan
-    };
-
-
-    const loadChannel = async () => {
-        setLoading(true)
-        let res = await API.get(`/api/channel/${channelId}`);
-        const {success, message, data} = res.data;
-        if (success) {
-            if (data.models === '') {
-                data.models = [];
-            } else {
-                data.models = data.models.split(',');
-            }
-            if (data.group === '') {
-                data.groups = [];
-            } else {
-                data.groups = data.group.split(',');
-            }
-            if (data.model_mapping !== '') {
-                data.model_mapping = JSON.stringify(JSON.parse(data.model_mapping), null, 2);
-            }
-            setInputs(data);
-            if (data.auto_ban === 0) {
-                setAutoBan(false);
-            } else {
-                setAutoBan(true);
-            }
-            // console.log(data);
-        } else {
-            showError(message);
-        }
-        setLoading(false);
-    };
+  const loadChannel = async () => {
+    setLoading(true);
+    let res = await API.get(`/api/channel/${channelId}`);
+    const { success, message, data } = res.data;
+    if (success) {
+      if (data.models === '') {
+        data.models = [];
+      } else {
+        data.models = data.models.split(',');
+      }
+      if (data.group === '') {
+        data.groups = [];
+      } else {
+        data.groups = data.group.split(',');
+      }
+      if (data.model_mapping !== '') {
+        data.model_mapping = JSON.stringify(
+          JSON.parse(data.model_mapping),
+          null,
+          2,
+        );
+      }
+      setInputs(data);
+      if (data.auto_ban === 0) {
+        setAutoBan(false);
+      } else {
+        setAutoBan(true);
+      }
+      // console.log(data);
+    } else {
+      showError(message);
+    }
+    setLoading(false);
+  };
 
-    const fetchModels = async () => {
-        try {
-            let res = await API.get(`/api/channel/models`);
-            let localModelOptions = res.data.data.map((model) => ({
-                label: model.id,
-                value: model.id
-            }));
-            setOriginModelOptions(localModelOptions);
-            setFullModels(res.data.data.map((model) => model.id));
-            setBasicModels(res.data.data.filter((model) => {
-                return model.id.startsWith('gpt-3') || model.id.startsWith('text-');
-            }).map((model) => model.id));
-        } catch (error) {
-            showError(error.message);
-        }
-    };
+  const fetchModels = async () => {
+    try {
+      let res = await API.get(`/api/channel/models`);
+      let localModelOptions = res.data.data.map((model) => ({
+        label: model.id,
+        value: model.id,
+      }));
+      setOriginModelOptions(localModelOptions);
+      setFullModels(res.data.data.map((model) => model.id));
+      setBasicModels(
+        res.data.data
+          .filter((model) => {
+            return model.id.startsWith('gpt-3') || model.id.startsWith('text-');
+          })
+          .map((model) => model.id),
+      );
+    } catch (error) {
+      showError(error.message);
+    }
+  };
 
-    const fetchGroups = async () => {
-        try {
-            let res = await API.get(`/api/group/`);
-            setGroupOptions(res.data.data.map((group) => ({
-                label: group,
-                value: group
-            })));
-        } catch (error) {
-            showError(error.message);
-        }
-    };
+  const fetchGroups = async () => {
+    try {
+      let res = await API.get(`/api/group/`);
+      setGroupOptions(
+        res.data.data.map((group) => ({
+          label: group,
+          value: group,
+        })),
+      );
+    } catch (error) {
+      showError(error.message);
+    }
+  };
 
-    useEffect(() => {
-        let localModelOptions = [...originModelOptions];
-        inputs.models.forEach((model) => {
-            if (!localModelOptions.find((option) => option.key === model)) {
-                localModelOptions.push({
-                    label: model,
-                    value: model
-                });
-            }
+  useEffect(() => {
+    let localModelOptions = [...originModelOptions];
+    inputs.models.forEach((model) => {
+      if (!localModelOptions.find((option) => option.key === model)) {
+        localModelOptions.push({
+          label: model,
+          value: model,
         });
-        setModelOptions(localModelOptions);
-    }, [originModelOptions, inputs.models]);
+      }
+    });
+    setModelOptions(localModelOptions);
+  }, [originModelOptions, inputs.models]);
 
-    useEffect(() => {
-        fetchModels().then();
-        fetchGroups().then();
-        if (isEdit) {
-            loadChannel().then(
-                () => {
+  useEffect(() => {
+    fetchModels().then();
+    fetchGroups().then();
+    if (isEdit) {
+      loadChannel().then(() => {});
+    } else {
+      setInputs(originInputs);
+    }
+  }, [props.editingChannel.id]);
 
-                }
-            );
-        } else {
-            setInputs(originInputs)
-        }
-    }, [props.editingChannel.id]);
+  const submit = async () => {
+    if (!isEdit && (inputs.name === '' || inputs.key === '')) {
+      showInfo('请填写渠道名称和渠道密钥!');
+      return;
+    }
+    if (inputs.models.length === 0) {
+      showInfo('请至少选择一个模型!');
+      return;
+    }
+    if (inputs.model_mapping !== '' && !verifyJSON(inputs.model_mapping)) {
+      showInfo('模型映射必须是合法的 JSON 格式!');
+      return;
+    }
+    let localInputs = { ...inputs };
+    if (localInputs.base_url && localInputs.base_url.endsWith('/')) {
+      localInputs.base_url = localInputs.base_url.slice(
+        0,
+        localInputs.base_url.length - 1,
+      );
+    }
+    if (localInputs.type === 3 && localInputs.other === '') {
+      localInputs.other = '2023-06-01-preview';
+    }
+    if (localInputs.type === 18 && localInputs.other === '') {
+      localInputs.other = 'v2.1';
+    }
+    let res;
+    if (!Array.isArray(localInputs.models)) {
+      showError('提交失败,请勿重复提交!');
+      handleCancel();
+      return;
+    }
+    localInputs.auto_ban = autoBan ? 1 : 0;
+    localInputs.models = localInputs.models.join(',');
+    localInputs.group = localInputs.groups.join(',');
+    if (isEdit) {
+      res = await API.put(`/api/channel/`, {
+        ...localInputs,
+        id: parseInt(channelId),
+      });
+    } else {
+      res = await API.post(`/api/channel/`, localInputs);
+    }
+    const { success, message } = res.data;
+    if (success) {
+      if (isEdit) {
+        showSuccess('渠道更新成功!');
+      } else {
+        showSuccess('渠道创建成功!');
+        setInputs(originInputs);
+      }
+      props.refresh();
+      props.handleClose();
+    } else {
+      showError(message);
+    }
+  };
 
+  const addCustomModel = () => {
+    if (customModel.trim() === '') return;
+    if (inputs.models.includes(customModel)) return showError('该模型已存在!');
+    let localModels = [...inputs.models];
+    localModels.push(customModel);
+    let localModelOptions = [];
+    localModelOptions.push({
+      key: customModel,
+      text: customModel,
+      value: customModel,
+    });
+    setModelOptions((modelOptions) => {
+      return [...modelOptions, ...localModelOptions];
+    });
+    setCustomModel('');
+    handleInputChange('models', localModels);
+  };
 
-    const submit = async () => {
-        if (!isEdit && (inputs.name === '' || inputs.key === '')) {
-            showInfo('请填写渠道名称和渠道密钥!');
-            return;
-        }
-        if (inputs.models.length === 0) {
-            showInfo('请至少选择一个模型!');
-            return;
-        }
-        if (inputs.model_mapping !== '' && !verifyJSON(inputs.model_mapping)) {
-            showInfo('模型映射必须是合法的 JSON 格式!');
-            return;
-        }
-        let localInputs = {...inputs};
-        if (localInputs.base_url && localInputs.base_url.endsWith('/')) {
-            localInputs.base_url = localInputs.base_url.slice(0, localInputs.base_url.length - 1);
-        }
-        if (localInputs.type === 3 && localInputs.other === '') {
-            localInputs.other = '2023-06-01-preview';
-        }
-        if (localInputs.type === 18 && localInputs.other === '') {
-            localInputs.other = 'v2.1';
-        }
-        let res;
-        if (!Array.isArray(localInputs.models)) {
-            showError('提交失败,请勿重复提交!');
-            handleCancel();
-            return;
+  return (
+    <>
+      <SideSheet
+        maskClosable={false}
+        placement={isEdit ? 'right' : 'left'}
+        title={
+          <Title level={3}>{isEdit ? '更新渠道信息' : '创建新的渠道'}</Title>
         }
-        localInputs.auto_ban = autoBan ? 1 : 0;
-        localInputs.models = localInputs.models.join(',');
-        localInputs.group = localInputs.groups.join(',');
-        if (isEdit) {
-            res = await API.put(`/api/channel/`, {...localInputs, id: parseInt(channelId)});
-        } else {
-            res = await API.post(`/api/channel/`, localInputs);
+        headerStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
+        bodyStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
+        visible={props.visible}
+        footer={
+          <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
+            <Space>
+              <Button theme='solid' size={'large'} onClick={submit}>
+                提交
+              </Button>
+              <Button
+                theme='solid'
+                size={'large'}
+                type={'tertiary'}
+                onClick={handleCancel}
+              >
+                取消
+              </Button>
+            </Space>
+          </div>
         }
-        const {success, message} = res.data;
-        if (success) {
-            if (isEdit) {
-                showSuccess('渠道更新成功!');
-            } else {
-                showSuccess('渠道创建成功!');
-                setInputs(originInputs);
-            }
-            props.refresh();
-            props.handleClose();
-        } else {
-            showError(message);
-        }
-    };
-
-    const addCustomModel = () => {
-        if (customModel.trim() === '') return;
-        if (inputs.models.includes(customModel)) return showError("该模型已存在!");
-        let localModels = [...inputs.models];
-        localModels.push(customModel);
-        let localModelOptions = [];
-        localModelOptions.push({
-            key: customModel,
-            text: customModel,
-            value: customModel
-        });
-        setModelOptions(modelOptions => {
-            return [...modelOptions, ...localModelOptions];
-        });
-        setCustomModel('');
-        handleInputChange('models', localModels);
-    };
-
-    return (
-        <>
-            <SideSheet
-                maskClosable={false}
-                placement={isEdit ? 'right' : 'left'}
-                title={<Title level={3}>{isEdit ? '更新渠道信息' : '创建新的渠道'}</Title>}
-                headerStyle={{borderBottom: '1px solid var(--semi-color-border)'}}
-                bodyStyle={{borderBottom: '1px solid var(--semi-color-border)'}}
-                visible={props.visible}
-                footer={
-                    <div style={{display: 'flex', justifyContent: 'flex-end'}}>
-                        <Space>
-                            <Button theme='solid' size={'large'} onClick={submit}>提交</Button>
-                            <Button theme='solid' size={'large'} type={'tertiary'} onClick={handleCancel}>取消</Button>
-                        </Space>
-                    </div>
+        closeIcon={null}
+        onCancel={() => handleCancel()}
+        width={isMobile() ? '100%' : 600}
+      >
+        <Spin spinning={loading}>
+          <div style={{ marginTop: 10 }}>
+            <Typography.Text strong>类型:</Typography.Text>
+          </div>
+          <Select
+            name='type'
+            required
+            optionList={CHANNEL_OPTIONS}
+            value={inputs.type}
+            onChange={(value) => handleInputChange('type', value)}
+            style={{ width: '50%' }}
+          />
+          {inputs.type === 3 && (
+            <>
+              <div style={{ marginTop: 10 }}>
+                <Banner
+                  type={'warning'}
+                  description={
+                    <>
+                      注意,<strong>模型部署名称必须和模型名称保持一致</strong>
+                      ,因为 One API 会把请求体中的 model
+                      参数替换为你的部署名称(模型名称中的点会被剔除),
+                      <a
+                        target='_blank'
+                        href='https://github.com/songquanpeng/one-api/issues/133?notification_referrer_id=NT_kwDOAmJSYrM2NjIwMzI3NDgyOjM5OTk4MDUw#issuecomment-1571602271'
+                      >
+                        图片演示
+                      </a>
+                      。
+                    </>
+                  }
+                ></Banner>
+              </div>
+              <div style={{ marginTop: 10 }}>
+                <Typography.Text strong>
+                  AZURE_OPENAI_ENDPOINT:
+                </Typography.Text>
+              </div>
+              <Input
+                label='AZURE_OPENAI_ENDPOINT'
+                name='azure_base_url'
+                placeholder={
+                  '请输入 AZURE_OPENAI_ENDPOINT,例如:https://docs-test-001.openai.azure.com'
                 }
-                closeIcon={null}
-                onCancel={() => handleCancel()}
-                width={isMobile() ? '100%' : 600}
-            >
-                <Spin spinning={loading}>
-                    <div style={{marginTop: 10}}>
-                        <Typography.Text strong>类型:</Typography.Text>
-                    </div>
-                    <Select
-                        name='type'
-                        required
-                        optionList={CHANNEL_OPTIONS}
-                        value={inputs.type}
-                        onChange={value => handleInputChange('type', value)}
-                        style={{width: '50%'}}
-                    />
-                    {
-                        inputs.type === 3 && (
-                            <>
-                                <div style={{marginTop: 10}}>
-                                    <Banner type={"warning"} description={
-                                        <>
-                                            注意,<strong>模型部署名称必须和模型名称保持一致</strong>,因为 One API 会把请求体中的
-                                            model
-                                            参数替换为你的部署名称(模型名称中的点会被剔除),<a target='_blank'
-                                                                                              href='https://github.com/songquanpeng/one-api/issues/133?notification_referrer_id=NT_kwDOAmJSYrM2NjIwMzI3NDgyOjM5OTk4MDUw#issuecomment-1571602271'>图片演示</a>。
-                                        </>
-                                    }>
-                                    </Banner>
-                                </div>
-                                <div style={{marginTop: 10}}>
-                                    <Typography.Text strong>AZURE_OPENAI_ENDPOINT:</Typography.Text>
-                                </div>
-                                <Input
-                                    label='AZURE_OPENAI_ENDPOINT'
-                                    name='azure_base_url'
-                                    placeholder={'请输入 AZURE_OPENAI_ENDPOINT,例如:https://docs-test-001.openai.azure.com'}
-                                    onChange={value => {
-                                        handleInputChange('base_url', value)
-                                    }}
-                                    value={inputs.base_url}
-                                    autoComplete='new-password'
-                                />
-                                <div style={{marginTop: 10}}>
-                                    <Typography.Text strong>默认 API 版本:</Typography.Text>
-                                </div>
-                                <Input
-                                    label='默认 API 版本'
-                                    name='azure_other'
-                                    placeholder={'请输入默认 API 版本,例如:2023-06-01-preview,该配置可以被实际的请求查询参数所覆盖'}
-                                    onChange={value => {
-                                        handleInputChange('other', value)
-                                    }}
-                                    value={inputs.other}
-                                    autoComplete='new-password'
-                                />
-                            </>
-                        )
-                    }
-                    {
-                        inputs.type === 8 && (
-                            <>
-                                <div style={{marginTop: 10}}>
-                                    <Typography.Text strong>Base URL:</Typography.Text>
-                                </div>
-                                <Input
-                                    name='base_url'
-                                    placeholder={'请输入自定义渠道的 Base URL'}
-                                    onChange={value => {
-                                        handleInputChange('base_url', value)
-                                    }}
-                                    value={inputs.base_url}
-                                    autoComplete='new-password'
-                                />
-                            </>
-                        )
-                    }
-                    <div style={{marginTop: 10}}>
-                        <Typography.Text strong>名称:</Typography.Text>
-                    </div>
-                    <Input
-                        required
-                        name='name'
-                        placeholder={'请为渠道命名'}
-                        onChange={value => {
-                            handleInputChange('name', value)
-                        }}
-                        value={inputs.name}
-                        autoComplete='new-password'
-                    />
-                    <div style={{marginTop: 10}}>
-                        <Typography.Text strong>分组:</Typography.Text>
-                    </div>
-                    <Select
-                        placeholder={'请选择可以使用该渠道的分组'}
-                        name='groups'
-                        required
-                        multiple
-                        selection
-                        allowAdditions
-                        additionLabel={'请在系统设置页面编辑分组倍率以添加新的分组:'}
-                        onChange={value => {
-                            handleInputChange('groups', value)
-                        }}
-                        value={inputs.groups}
-                        autoComplete='new-password'
-                        optionList={groupOptions}
-                    />
-                    {
-                        inputs.type === 18 && (
-                            <>
-                                <div style={{marginTop: 10}}>
-                                    <Typography.Text strong>模型版本:</Typography.Text>
-                                </div>
-                                <Input
-                                    name='other'
-                                    placeholder={'请输入星火大模型版本,注意是接口地址中的版本号,例如:v2.1'}
-                                    onChange={value => {
-                                        handleInputChange('other', value)
-                                    }}
-                                    value={inputs.other}
-                                    autoComplete='new-password'
-                                />
-                            </>
-                        )
-                    }
-                    {
-                        inputs.type === 21 && (
-                            <>
-                                <div style={{marginTop: 10}}>
-                                    <Typography.Text strong>知识库 ID:</Typography.Text>
-                                </div>
-                                <Input
-                                    label='知识库 ID'
-                                    name='other'
-                                    placeholder={'请输入知识库 ID,例如:123456'}
-                                    onChange={value => {
-                                        handleInputChange('other', value)
-                                    }}
-                                    value={inputs.other}
-                                    autoComplete='new-password'
-                                />
-                            </>
-                        )
-                    }
-                    <div style={{marginTop: 10}}>
-                        <Typography.Text strong>模型:</Typography.Text>
-                    </div>
-                    <Select
-                        placeholder={'请选择该渠道所支持的模型'}
-                        name='models'
-                        required
-                        multiple
-                        selection
-                        onChange={value => {
-                            handleInputChange('models', value)
-                        }}
-                        value={inputs.models}
-                        autoComplete='new-password'
-                        optionList={modelOptions}
-                    />
-                    <div style={{lineHeight: '40px', marginBottom: '12px'}}>
-                        <Space>
-                            <Button type='primary' onClick={() => {
-                                handleInputChange('models', basicModels);
-                            }}>填入基础模型</Button>
-                            <Button type='secondary' onClick={() => {
-                                handleInputChange('models', fullModels);
-                            }}>填入所有模型</Button>
-                            <Button type='warning' onClick={() => {
-                                handleInputChange('models', []);
-                            }}>清除所有模型</Button>
-                        </Space>
-                        <Input
-                            addonAfter={
-                                <Button type='primary' onClick={addCustomModel}>填入</Button>
-                            }
-                            placeholder='输入自定义模型名称'
-                            value={customModel}
-                            onChange={(value) => {
-                                setCustomModel(value.trim());
-                            }}
-                        />
-                    </div>
-                    <div style={{marginTop: 10}}>
-                        <Typography.Text strong>模型重定向:</Typography.Text>
-                    </div>
-                    <TextArea
-                        placeholder={`此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,例如:\n${JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2)}`}
-                        name='model_mapping'
-                        onChange={value => {
-                            handleInputChange('model_mapping', value)
-                        }}
-                        autosize
-                        value={inputs.model_mapping}
-                        autoComplete='new-password'
-                    />
-                    <Typography.Text style={{
-                        color: 'rgba(var(--semi-blue-5), 1)',
-                        userSelect: 'none',
-                        cursor: 'pointer'
-                    }} onClick={
-                        () => {
-                            handleInputChange('model_mapping', JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2))
-                        }
-                    }>
-                        填入模板
-                    </Typography.Text>
-                    <div style={{marginTop: 10}}>
-                        <Typography.Text strong>密钥:</Typography.Text>
-                    </div>
-                    {
-                        batch ?
-                            <TextArea
-                                label='密钥'
-                                name='key'
-                                required
-                                placeholder={'请输入密钥,一行一个'}
-                                onChange={value => {
-                                    handleInputChange('key', value)
-                                }}
-                                value={inputs.key}
-                                style={{minHeight: 150, fontFamily: 'JetBrains Mono, Consolas'}}
-                                autoComplete='new-password'
-                            />
-                            :
-                            <Input
-                                label='密钥'
-                                name='key'
-                                required
-                                placeholder={type2secretPrompt(inputs.type)}
-                                onChange={value => {
-                                    handleInputChange('key', value)
-                                }}
-                                value={inputs.key}
-                                autoComplete='new-password'
-                            />
-                    }
-                    <div style={{marginTop: 10}}>
-                        <Typography.Text strong>组织:</Typography.Text>
-                    </div>
-                    <Input
-                        label='组织,可选,不填则为默认组织'
-                        name='openai_organization'
-                        placeholder='请输入组织org-xxx'
-                        onChange={value => {
-                            handleInputChange('openai_organization', value)
-                        }}
-                        value={inputs.openai_organization}
-                    />
-                    <div style={{marginTop: 10, display: 'flex'}}>
-                        <Space>
-                            <Checkbox
-                                name='auto_ban'
-                                checked={autoBan}
-                                onChange={
-                                    () => {
-                                        setAutoBan(!autoBan);
-                                    }
-                                }
-                                // onChange={handleInputChange}
-                            />
-                            <Typography.Text
-                                strong>是否自动禁用(仅当自动禁用开启时有效),关闭后不会自动禁用该渠道:</Typography.Text>
-                        </Space>
-                    </div>
-
-                    {
-                        !isEdit && (
-                            <div style={{marginTop: 10, display: 'flex'}}>
-                                <Space>
-                                    <Checkbox
-                                        checked={batch}
-                                        label='批量创建'
-                                        name='batch'
-                                        onChange={() => setBatch(!batch)}
-                                    />
-                                    <Typography.Text strong>批量创建</Typography.Text>
-                                </Space>
-                            </div>
-                        )
-                    }
-                    {
-                        inputs.type !== 3 && inputs.type !== 8 && inputs.type !== 22 && (
-                            <>
-                                <div style={{marginTop: 10}}>
-                                    <Typography.Text strong>代理:</Typography.Text>
-                                </div>
-                                <Input
-                                    label='代理'
-                                    name='base_url'
-                                    placeholder={'此项可选,用于通过代理站来进行 API 调用'}
-                                    onChange={value => {
-                                        handleInputChange('base_url', value)
-                                    }}
-                                    value={inputs.base_url}
-                                    autoComplete='new-password'
-                                />
-                            </>
-                        )
-                    }
-                    {
-                        inputs.type === 22 && (
-                            <>
-                                <div style={{marginTop: 10}}>
-                                    <Typography.Text strong>私有部署地址:</Typography.Text>
-                                </div>
-                                <Input
-                                    name='base_url'
-                                    placeholder={'请输入私有部署地址,格式为:https://fastgpt.run/api/openapi'}
-                                    onChange={value => {
-                                        handleInputChange('base_url', value)
-                                    }}
-                                    value={inputs.base_url}
-                                    autoComplete='new-password'
-                                />
-                            </>
-                        )
-                    }
+                onChange={(value) => {
+                  handleInputChange('base_url', value);
+                }}
+                value={inputs.base_url}
+                autoComplete='new-password'
+              />
+              <div style={{ marginTop: 10 }}>
+                <Typography.Text strong>默认 API 版本:</Typography.Text>
+              </div>
+              <Input
+                label='默认 API 版本'
+                name='azure_other'
+                placeholder={
+                  '请输入默认 API 版本,例如:2023-06-01-preview,该配置可以被实际的请求查询参数所覆盖'
+                }
+                onChange={(value) => {
+                  handleInputChange('other', value);
+                }}
+                value={inputs.other}
+                autoComplete='new-password'
+              />
+            </>
+          )}
+          {inputs.type === 8 && (
+            <>
+              <div style={{ marginTop: 10 }}>
+                <Typography.Text strong>Base URL:</Typography.Text>
+              </div>
+              <Input
+                name='base_url'
+                placeholder={'请输入自定义渠道的 Base URL'}
+                onChange={(value) => {
+                  handleInputChange('base_url', value);
+                }}
+                value={inputs.base_url}
+                autoComplete='new-password'
+              />
+            </>
+          )}
+          <div style={{ marginTop: 10 }}>
+            <Typography.Text strong>名称:</Typography.Text>
+          </div>
+          <Input
+            required
+            name='name'
+            placeholder={'请为渠道命名'}
+            onChange={(value) => {
+              handleInputChange('name', value);
+            }}
+            value={inputs.name}
+            autoComplete='new-password'
+          />
+          <div style={{ marginTop: 10 }}>
+            <Typography.Text strong>分组:</Typography.Text>
+          </div>
+          <Select
+            placeholder={'请选择可以使用该渠道的分组'}
+            name='groups'
+            required
+            multiple
+            selection
+            allowAdditions
+            additionLabel={'请在系统设置页面编辑分组倍率以添加新的分组:'}
+            onChange={(value) => {
+              handleInputChange('groups', value);
+            }}
+            value={inputs.groups}
+            autoComplete='new-password'
+            optionList={groupOptions}
+          />
+          {inputs.type === 18 && (
+            <>
+              <div style={{ marginTop: 10 }}>
+                <Typography.Text strong>模型版本:</Typography.Text>
+              </div>
+              <Input
+                name='other'
+                placeholder={
+                  '请输入星火大模型版本,注意是接口地址中的版本号,例如:v2.1'
+                }
+                onChange={(value) => {
+                  handleInputChange('other', value);
+                }}
+                value={inputs.other}
+                autoComplete='new-password'
+              />
+            </>
+          )}
+          {inputs.type === 21 && (
+            <>
+              <div style={{ marginTop: 10 }}>
+                <Typography.Text strong>知识库 ID:</Typography.Text>
+              </div>
+              <Input
+                label='知识库 ID'
+                name='other'
+                placeholder={'请输入知识库 ID,例如:123456'}
+                onChange={(value) => {
+                  handleInputChange('other', value);
+                }}
+                value={inputs.other}
+                autoComplete='new-password'
+              />
+            </>
+          )}
+          <div style={{ marginTop: 10 }}>
+            <Typography.Text strong>模型:</Typography.Text>
+          </div>
+          <Select
+            placeholder={'请选择该渠道所支持的模型'}
+            name='models'
+            required
+            multiple
+            selection
+            onChange={(value) => {
+              handleInputChange('models', value);
+            }}
+            value={inputs.models}
+            autoComplete='new-password'
+            optionList={modelOptions}
+          />
+          <div style={{ lineHeight: '40px', marginBottom: '12px' }}>
+            <Space>
+              <Button
+                type='primary'
+                onClick={() => {
+                  handleInputChange('models', basicModels);
+                }}
+              >
+                填入基础模型
+              </Button>
+              <Button
+                type='secondary'
+                onClick={() => {
+                  handleInputChange('models', fullModels);
+                }}
+              >
+                填入所有模型
+              </Button>
+              <Button
+                type='warning'
+                onClick={() => {
+                  handleInputChange('models', []);
+                }}
+              >
+                清除所有模型
+              </Button>
+            </Space>
+            <Input
+              addonAfter={
+                <Button type='primary' onClick={addCustomModel}>
+                  填入
+                </Button>
+              }
+              placeholder='输入自定义模型名称'
+              value={customModel}
+              onChange={(value) => {
+                setCustomModel(value.trim());
+              }}
+            />
+          </div>
+          <div style={{ marginTop: 10 }}>
+            <Typography.Text strong>模型重定向:</Typography.Text>
+          </div>
+          <TextArea
+            placeholder={`此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,例如:\n${JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2)}`}
+            name='model_mapping'
+            onChange={(value) => {
+              handleInputChange('model_mapping', value);
+            }}
+            autosize
+            value={inputs.model_mapping}
+            autoComplete='new-password'
+          />
+          <Typography.Text
+            style={{
+              color: 'rgba(var(--semi-blue-5), 1)',
+              userSelect: 'none',
+              cursor: 'pointer',
+            }}
+            onClick={() => {
+              handleInputChange(
+                'model_mapping',
+                JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2),
+              );
+            }}
+          >
+            填入模板
+          </Typography.Text>
+          <div style={{ marginTop: 10 }}>
+            <Typography.Text strong>密钥:</Typography.Text>
+          </div>
+          {batch ? (
+            <TextArea
+              label='密钥'
+              name='key'
+              required
+              placeholder={'请输入密钥,一行一个'}
+              onChange={(value) => {
+                handleInputChange('key', value);
+              }}
+              value={inputs.key}
+              style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }}
+              autoComplete='new-password'
+            />
+          ) : (
+            <Input
+              label='密钥'
+              name='key'
+              required
+              placeholder={type2secretPrompt(inputs.type)}
+              onChange={(value) => {
+                handleInputChange('key', value);
+              }}
+              value={inputs.key}
+              autoComplete='new-password'
+            />
+          )}
+          <div style={{ marginTop: 10 }}>
+            <Typography.Text strong>组织:</Typography.Text>
+          </div>
+          <Input
+            label='组织,可选,不填则为默认组织'
+            name='openai_organization'
+            placeholder='请输入组织org-xxx'
+            onChange={(value) => {
+              handleInputChange('openai_organization', value);
+            }}
+            value={inputs.openai_organization}
+          />
+          <div style={{ marginTop: 10, display: 'flex' }}>
+            <Space>
+              <Checkbox
+                name='auto_ban'
+                checked={autoBan}
+                onChange={() => {
+                  setAutoBan(!autoBan);
+                }}
+                // onChange={handleInputChange}
+              />
+              <Typography.Text strong>
+                是否自动禁用(仅当自动禁用开启时有效),关闭后不会自动禁用该渠道:
+              </Typography.Text>
+            </Space>
+          </div>
 
-                </Spin>
-            </SideSheet>
-        </>
-    );
+          {!isEdit && (
+            <div style={{ marginTop: 10, display: 'flex' }}>
+              <Space>
+                <Checkbox
+                  checked={batch}
+                  label='批量创建'
+                  name='batch'
+                  onChange={() => setBatch(!batch)}
+                />
+                <Typography.Text strong>批量创建</Typography.Text>
+              </Space>
+            </div>
+          )}
+          {inputs.type !== 3 && inputs.type !== 8 && inputs.type !== 22 && (
+            <>
+              <div style={{ marginTop: 10 }}>
+                <Typography.Text strong>代理:</Typography.Text>
+              </div>
+              <Input
+                label='代理'
+                name='base_url'
+                placeholder={'此项可选,用于通过代理站来进行 API 调用'}
+                onChange={(value) => {
+                  handleInputChange('base_url', value);
+                }}
+                value={inputs.base_url}
+                autoComplete='new-password'
+              />
+            </>
+          )}
+          {inputs.type === 22 && (
+            <>
+              <div style={{ marginTop: 10 }}>
+                <Typography.Text strong>私有部署地址:</Typography.Text>
+              </div>
+              <Input
+                name='base_url'
+                placeholder={
+                  '请输入私有部署地址,格式为:https://fastgpt.run/api/openapi'
+                }
+                onChange={(value) => {
+                  handleInputChange('base_url', value);
+                }}
+                value={inputs.base_url}
+                autoComplete='new-password'
+              />
+            </>
+          )}
+        </Spin>
+      </SideSheet>
+    </>
+  );
 };
 
 export default EditChannel;

+ 11 - 11
web/src/pages/Channel/index.js

@@ -1,18 +1,18 @@
 import React from 'react';
 import ChannelsTable from '../../components/ChannelsTable';
-import {Layout} from "@douyinfe/semi-ui";
+import { Layout } from '@douyinfe/semi-ui';
 
 const File = () => (
-    <>
-        <Layout>
-            <Layout.Header>
-                <h3>管理渠道</h3>
-            </Layout.Header>
-            <Layout.Content>
-                <ChannelsTable/>
-            </Layout.Content>
-        </Layout>
-    </>
+  <>
+    <Layout>
+      <Layout.Header>
+        <h3>管理渠道</h3>
+      </Layout.Header>
+      <Layout.Content>
+        <ChannelsTable />
+      </Layout.Content>
+    </Layout>
+  </>
 );
 
 export default File;

+ 0 - 1
web/src/pages/Chat/index.js

@@ -11,5 +11,4 @@ const Chat = () => {
   );
 };
 
-
 export default Chat;

+ 396 - 337
web/src/pages/Detail/index.js

@@ -1,364 +1,423 @@
-import React, {useEffect, useRef, useState} from 'react';
+import React, { useEffect, useRef, useState } from 'react';
 import { initVChartSemiTheme } from '@visactor/vchart-semi-theme';
 
-import {Button, Col, Form, Layout, Row, Spin} from "@douyinfe/semi-ui";
+import { Button, Col, Form, Layout, Row, Spin } from '@douyinfe/semi-ui';
 import VChart from '@visactor/vchart';
-import {API, isAdmin, showError, timestamp2string, timestamp2string1} from "../../helpers";
 import {
-    getQuotaWithUnit, modelColorMap,
-    renderNumber,
-    renderQuota,
-    renderQuotaNumberWithDigit,
-    stringToColor
-} from "../../helpers/render";
+  API,
+  isAdmin,
+  showError,
+  timestamp2string,
+  timestamp2string1,
+} from '../../helpers';
+import {
+  getQuotaWithUnit,
+  modelColorMap,
+  renderNumber,
+  renderQuota,
+  renderQuotaNumberWithDigit,
+  stringToColor,
+} from '../../helpers/render';
 
 const Detail = (props) => {
-    const formRef = useRef();
-    let now = new Date();
-    const [inputs, setInputs] = useState({
-        username: '',
-        token_name: '',
-        model_name: '',
-        start_timestamp: localStorage.getItem('data_export_default_time') === 'hour' ? timestamp2string(now.getTime() / 1000 - 86400) : (localStorage.getItem('data_export_default_time') === 'week' ? timestamp2string(now.getTime() / 1000 - 86400 * 30) : timestamp2string(now.getTime() / 1000 - 86400 * 7)),
-        end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),
-        channel: '',
-        data_export_default_time: ''
-    });
-    const {username, model_name, start_timestamp, end_timestamp, channel} = inputs;
-    const isAdminUser = isAdmin();
-    const initialized = useRef(false)
-    const [modelDataChart, setModelDataChart] = useState(null);
-    const [modelDataPieChart, setModelDataPieChart] = useState(null);
-    const [loading, setLoading] = useState(false);
-    const [quotaData, setQuotaData] = useState([]);
-    const [consumeQuota, setConsumeQuota] = useState(0);
-    const [times, setTimes] = useState(0);
-    const [dataExportDefaultTime, setDataExportDefaultTime] = useState(localStorage.getItem('data_export_default_time') || 'hour');
+  const formRef = useRef();
+  let now = new Date();
+  const [inputs, setInputs] = useState({
+    username: '',
+    token_name: '',
+    model_name: '',
+    start_timestamp:
+      localStorage.getItem('data_export_default_time') === 'hour'
+        ? timestamp2string(now.getTime() / 1000 - 86400)
+        : localStorage.getItem('data_export_default_time') === 'week'
+          ? timestamp2string(now.getTime() / 1000 - 86400 * 30)
+          : timestamp2string(now.getTime() / 1000 - 86400 * 7),
+    end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),
+    channel: '',
+    data_export_default_time: '',
+  });
+  const { username, model_name, start_timestamp, end_timestamp, channel } =
+    inputs;
+  const isAdminUser = isAdmin();
+  const initialized = useRef(false);
+  const [modelDataChart, setModelDataChart] = useState(null);
+  const [modelDataPieChart, setModelDataPieChart] = useState(null);
+  const [loading, setLoading] = useState(false);
+  const [quotaData, setQuotaData] = useState([]);
+  const [consumeQuota, setConsumeQuota] = useState(0);
+  const [times, setTimes] = useState(0);
+  const [dataExportDefaultTime, setDataExportDefaultTime] = useState(
+    localStorage.getItem('data_export_default_time') || 'hour',
+  );
 
-    const handleInputChange = (value, name) => {
-        if (name === 'data_export_default_time') {
-            setDataExportDefaultTime(value);
-            return
-        }
-        setInputs((inputs) => ({...inputs, [name]: value}));
-    };
+  const handleInputChange = (value, name) => {
+    if (name === 'data_export_default_time') {
+      setDataExportDefaultTime(value);
+      return;
+    }
+    setInputs((inputs) => ({ ...inputs, [name]: value }));
+  };
 
-    const spec_line = {
-        type: 'bar',
-        data: [
-            {
-                id: 'barData',
-                values: []
-            }
-        ],
-        xField: 'Time',
-        yField: 'Usage',
-        seriesField: 'Model',
-        stack: true,
-        legends: {
-            visible: true
-        },
-        title: {
-            visible: true,
-            text: '模型消耗分布',
-            subtext: '0'
+  const spec_line = {
+    type: 'bar',
+    data: [
+      {
+        id: 'barData',
+        values: [],
+      },
+    ],
+    xField: 'Time',
+    yField: 'Usage',
+    seriesField: 'Model',
+    stack: true,
+    legends: {
+      visible: true,
+    },
+    title: {
+      visible: true,
+      text: '模型消耗分布',
+      subtext: '0',
+    },
+    bar: {
+      // The state style of bar
+      state: {
+        hover: {
+          stroke: '#000',
+          lineWidth: 1,
         },
-        bar: {
-            // The state style of bar
-            state: {
-                hover: {
-                    stroke: '#000',
-                    lineWidth: 1
-                }
-            }
-        },
-        tooltip: {
-            mark: {
-                content: [
-                    {
-                        key: datum => datum['Model'],
-                        value: datum => renderQuotaNumberWithDigit(parseFloat(datum['Usage']), 4)
-                    }
-                ]
-            },
-            dimension: {
-                content: [
-                    {
-                        key: datum => datum['Model'],
-                        value: datum => datum['Usage']
-                    }
-                ],
-                updateContent: array => {
-                    // sort by value
-                    array.sort((a, b) => b.value - a.value);
-                    // add $
-                    let sum = 0;
-                    for (let i = 0; i < array.length; i++) {
-                        sum += parseFloat(array[i].value);
-                        array[i].value = renderQuotaNumberWithDigit(parseFloat(array[i].value), 4);
-                    }
-                    // add to first
-                    array.unshift({
-                        key: '总计',
-                        value: renderQuotaNumberWithDigit(sum, 4)
-                    });
-                    return array;
-                }
-            }
-        },
-        color: {
-            specified: modelColorMap
-        }
-    };
-
-    const spec_pie = {
-        type: 'pie',
-        data: [
-            {
-                id: 'id0',
-                values: [
-                    {type: 'null', value: '0'},
-                ]
-            }
+      },
+    },
+    tooltip: {
+      mark: {
+        content: [
+          {
+            key: (datum) => datum['Model'],
+            value: (datum) =>
+              renderQuotaNumberWithDigit(parseFloat(datum['Usage']), 4),
+          },
         ],
-        outerRadius: 0.8,
-        innerRadius: 0.5,
-        padAngle: 0.6,
-        valueField: 'value',
-        categoryField: 'type',
-        pie: {
-            style: {
-                cornerRadius: 10
-            },
-            state: {
-                hover: {
-                    outerRadius: 0.85,
-                    stroke: '#000',
-                    lineWidth: 1
-                },
-                selected: {
-                    outerRadius: 0.85,
-                    stroke: '#000',
-                    lineWidth: 1
-                }
-            }
-        },
-        title: {
-            visible: true,
-            text: '模型调用次数占比'
-        },
-        legends: {
-            visible: true,
-            orient: 'left'
+      },
+      dimension: {
+        content: [
+          {
+            key: (datum) => datum['Model'],
+            value: (datum) => datum['Usage'],
+          },
+        ],
+        updateContent: (array) => {
+          // sort by value
+          array.sort((a, b) => b.value - a.value);
+          // add $
+          let sum = 0;
+          for (let i = 0; i < array.length; i++) {
+            sum += parseFloat(array[i].value);
+            array[i].value = renderQuotaNumberWithDigit(
+              parseFloat(array[i].value),
+              4,
+            );
+          }
+          // add to first
+          array.unshift({
+            key: '总计',
+            value: renderQuotaNumberWithDigit(sum, 4),
+          });
+          return array;
         },
-        label: {
-            visible: true
+      },
+    },
+    color: {
+      specified: modelColorMap,
+    },
+  };
+
+  const spec_pie = {
+    type: 'pie',
+    data: [
+      {
+        id: 'id0',
+        values: [{ type: 'null', value: '0' }],
+      },
+    ],
+    outerRadius: 0.8,
+    innerRadius: 0.5,
+    padAngle: 0.6,
+    valueField: 'value',
+    categoryField: 'type',
+    pie: {
+      style: {
+        cornerRadius: 10,
+      },
+      state: {
+        hover: {
+          outerRadius: 0.85,
+          stroke: '#000',
+          lineWidth: 1,
         },
-        tooltip: {
-            mark: {
-                content: [
-                    {
-                        key: datum => datum['type'],
-                        value: datum => renderNumber(datum['value'])
-                    }
-                ]
-            }
+        selected: {
+          outerRadius: 0.85,
+          stroke: '#000',
+          lineWidth: 1,
         },
-        color: {
-            specified: modelColorMap
-        }
-    };
+      },
+    },
+    title: {
+      visible: true,
+      text: '模型调用次数占比',
+    },
+    legends: {
+      visible: true,
+      orient: 'left',
+    },
+    label: {
+      visible: true,
+    },
+    tooltip: {
+      mark: {
+        content: [
+          {
+            key: (datum) => datum['type'],
+            value: (datum) => renderNumber(datum['value']),
+          },
+        ],
+      },
+    },
+    color: {
+      specified: modelColorMap,
+    },
+  };
+
+  const loadQuotaData = async (lineChart, pieChart) => {
+    setLoading(true);
 
-    const loadQuotaData = async (lineChart, pieChart) => {
-        setLoading(true);
+    let url = '';
+    let localStartTimestamp = Date.parse(start_timestamp) / 1000;
+    let localEndTimestamp = Date.parse(end_timestamp) / 1000;
+    if (isAdminUser) {
+      url = `/api/data/?username=${username}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;
+    } else {
+      url = `/api/data/self/?start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;
+    }
+    const res = await API.get(url);
+    const { success, message, data } = res.data;
+    if (success) {
+      setQuotaData(data);
+      if (data.length === 0) {
+        data.push({
+          count: 0,
+          model_name: '无数据',
+          quota: 0,
+          created_at: now.getTime() / 1000,
+        });
+      }
+      // 根据dataExportDefaultTime重制时间粒度
+      let timeGranularity = 3600;
+      if (dataExportDefaultTime === 'day') {
+        timeGranularity = 86400;
+      } else if (dataExportDefaultTime === 'week') {
+        timeGranularity = 604800;
+      }
+      data.forEach((item) => {
+        item['created_at'] =
+          Math.floor(item['created_at'] / timeGranularity) * timeGranularity;
+      });
+      updateChart(lineChart, pieChart, data);
+    } else {
+      showError(message);
+    }
+    setLoading(false);
+  };
 
-        let url = '';
-        let localStartTimestamp = Date.parse(start_timestamp) / 1000;
-        let localEndTimestamp = Date.parse(end_timestamp) / 1000;
-        if (isAdminUser) {
-            url = `/api/data/?username=${username}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;
-        } else {
-            url = `/api/data/self/?start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;
-        }
-        const res = await API.get(url);
-        const {success, message, data} = res.data;
-        if (success) {
-            setQuotaData(data);
-            if (data.length === 0) {
-                data.push({
-                    'count': 0,
-                    'model_name': '无数据',
-                    'quota': 0,
-                    'created_at': now.getTime() / 1000
-                })
-            }
-            // 根据dataExportDefaultTime重制时间粒度
-            let timeGranularity = 3600;
-            if (dataExportDefaultTime === 'day') {
-                timeGranularity = 86400;
-            } else if (dataExportDefaultTime === 'week') {
-                timeGranularity = 604800;
-            }
-            data.forEach(item => {
-                item['created_at'] = Math.floor(item['created_at'] / timeGranularity) * timeGranularity;
-            });
-            updateChart(lineChart, pieChart, data);
-        } else {
-            showError(message);
-        }
-        setLoading(false);
-    };
+  const refresh = async () => {
+    await loadQuotaData(modelDataChart, modelDataPieChart);
+  };
 
-    const refresh = async () => {
-        await loadQuotaData(modelDataChart, modelDataPieChart);
-    };
+  const initChart = async () => {
+    let lineChart = modelDataChart;
+    if (!modelDataChart) {
+      lineChart = new VChart(spec_line, { dom: 'model_data' });
+      setModelDataChart(lineChart);
+      lineChart.renderAsync();
+    }
+    let pieChart = modelDataPieChart;
+    if (!modelDataPieChart) {
+      pieChart = new VChart(spec_pie, { dom: 'model_pie' });
+      setModelDataPieChart(pieChart);
+      pieChart.renderAsync();
+    }
+    console.log('init vchart');
+    await loadQuotaData(lineChart, pieChart);
+  };
 
-    const initChart = async () => {
-        let lineChart = modelDataChart
-        if (!modelDataChart) {
-            lineChart = new VChart(spec_line, {dom: 'model_data'});
-            setModelDataChart(lineChart);
-            lineChart.renderAsync();
-        }
-        let pieChart = modelDataPieChart
-        if (!modelDataPieChart) {
-            pieChart = new VChart(spec_pie, {dom: 'model_pie'});
-            setModelDataPieChart(pieChart);
-            pieChart.renderAsync();
-        }
-        console.log('init vchart');
-        await loadQuotaData(lineChart, pieChart)
+  const updateChart = (lineChart, pieChart, data) => {
+    if (isAdminUser) {
+      // 将所有用户合并
+    }
+    let pieData = [];
+    let lineData = [];
+    let consumeQuota = 0;
+    let times = 0;
+    for (let i = 0; i < data.length; i++) {
+      const item = data[i];
+      consumeQuota += item.quota;
+      times += item.count;
+      // 合并model_name
+      let pieItem = pieData.find((it) => it.type === item.model_name);
+      if (pieItem) {
+        pieItem.value += item.count;
+      } else {
+        pieData.push({
+          type: item.model_name,
+          value: item.count,
+        });
+      }
+      // 合并created_at和model_name 为 lineData, created_at 数据类型是小时的时间戳
+      // 转换日期格式
+      let createTime = timestamp2string1(
+        item.created_at,
+        dataExportDefaultTime,
+      );
+      let lineItem = lineData.find(
+        (it) => it.Time === createTime && it.Model === item.model_name,
+      );
+      if (lineItem) {
+        lineItem.Usage += parseFloat(getQuotaWithUnit(item.quota));
+      } else {
+        lineData.push({
+          Time: createTime,
+          Model: item.model_name,
+          Usage: parseFloat(getQuotaWithUnit(item.quota)),
+        });
+      }
     }
+    setConsumeQuota(consumeQuota);
+    setTimes(times);
 
-    const updateChart = (lineChart, pieChart, data) => {
-        if (isAdminUser) {
-            // 将所有用户合并
-        }
-        let pieData = [];
-        let lineData = [];
-        let consumeQuota = 0;
-        let times = 0;
-        for (let i = 0; i < data.length; i++) {
-            const item = data[i];
-            consumeQuota += item.quota;
-            times += item.count;
-            // 合并model_name
-            let pieItem = pieData.find(it => it.type === item.model_name);
-            if (pieItem) {
-                pieItem.value += item.count;
-            } else {
-                pieData.push({
-                    "type": item.model_name,
-                    "value": item.count
-                });
-            }
-            // 合并created_at和model_name 为 lineData, created_at 数据类型是小时的时间戳
-            // 转换日期格式
-            let createTime = timestamp2string1(item.created_at, dataExportDefaultTime);
-            let lineItem = lineData.find(it => it.Time === createTime && it.Model === item.model_name);
-            if (lineItem) {
-                lineItem.Usage += parseFloat(getQuotaWithUnit(item.quota));
-            } else {
-                lineData.push({
-                    "Time": createTime,
-                    "Model": item.model_name,
-                    "Usage": parseFloat(getQuotaWithUnit(item.quota))
-                });
-            }
-        }
-        setConsumeQuota(consumeQuota);
-        setTimes(times);
+    // sort by count
+    pieData.sort((a, b) => b.value - a.value);
+    spec_pie.title.subtext = `总计:${renderNumber(times)}`;
+    spec_pie.data[0].values = pieData;
 
-        // sort by count
-        pieData.sort((a, b) => b.value - a.value);
-        spec_pie.title.subtext = `总计:${renderNumber(times)}`;
-        spec_pie.data[0].values = pieData;
+    spec_line.title.subtext = `总计:${renderQuota(consumeQuota, 2)}`;
+    spec_line.data[0].values = lineData;
+    pieChart.updateSpec(spec_pie);
+    lineChart.updateSpec(spec_line);
 
-        spec_line.title.subtext = `总计:${renderQuota(consumeQuota, 2)}`;
-        spec_line.data[0].values = lineData;
-        pieChart.updateSpec(spec_pie);
-        lineChart.updateSpec(spec_line);
+    // pieChart.updateData('id0', pieData);
+    // lineChart.updateData('barData', lineData);
+    pieChart.reLayout();
+    lineChart.reLayout();
+  };
 
-        // pieChart.updateData('id0', pieData);
-        // lineChart.updateData('barData', lineData);
-        pieChart.reLayout();
-        lineChart.reLayout();
+  useEffect(() => {
+    // setDataExportDefaultTime(localStorage.getItem('data_export_default_time'));
+    // if (dataExportDefaultTime === 'day') {
+    //     // 设置开始时间为7天前
+    //     let st = timestamp2string(now.getTime() / 1000 - 86400 * 7)
+    //     inputs.start_timestamp = st;
+    //     formRef.current.formApi.setValue('start_timestamp', st);
+    // }
+    if (!initialized.current) {
+      initVChartSemiTheme({
+        isWatchingThemeSwitch: true,
+      });
+      initialized.current = true;
+      initChart();
     }
+  }, []);
 
-    useEffect(() => {
-        // setDataExportDefaultTime(localStorage.getItem('data_export_default_time'));
-        // if (dataExportDefaultTime === 'day') {
-        //     // 设置开始时间为7天前
-        //     let st = timestamp2string(now.getTime() / 1000 - 86400 * 7)
-        //     inputs.start_timestamp = st;
-        //     formRef.current.formApi.setValue('start_timestamp', st);
-        // }
-        if (!initialized.current) {
-            initVChartSemiTheme({
-                isWatchingThemeSwitch: true,
-            });
-            initialized.current = true;
-            initChart();
-        }
-    }, []);
-
-    return (
-        <>
-            <Layout>
-                <Layout.Header>
-                    <h3>数据看板</h3>
-                </Layout.Header>
-                <Layout.Content>
-                    <Form ref={formRef} layout='horizontal' style={{marginTop: 10}}>
-                        <>
-                            <Form.DatePicker field="start_timestamp" label='起始时间' style={{width: 272}}
-                                             initValue={start_timestamp}
-                                             value={start_timestamp} type='dateTime'
-                                             name='start_timestamp'
-                                             onChange={value => handleInputChange(value, 'start_timestamp')}/>
-                            <Form.DatePicker field="end_timestamp" fluid label='结束时间' style={{width: 272}}
-                                             initValue={end_timestamp}
-                                             value={end_timestamp} type='dateTime'
-                                             name='end_timestamp'
-                                             onChange={value => handleInputChange(value, 'end_timestamp')}/>
-                            <Form.Select field="data_export_default_time" label='时间粒度' style={{width: 176}}
-                                         initValue={dataExportDefaultTime}
-                                         placeholder={'时间粒度'} name='data_export_default_time'
-                                         optionList={
-                                             [
-                                                 {label: '小时', value: 'hour'},
-                                                 {label: '天', value: 'day'},
-                                                 {label: '周', value: 'week'}
-                                             ]
-                                         }
-                                         onChange={value => handleInputChange(value, 'data_export_default_time')}>
-                            </Form.Select>
-                            {
-                                isAdminUser && <>
-                                    <Form.Input field="username" label='用户名称' style={{width: 176}} value={username}
-                                                placeholder={'可选值'} name='username'
-                                                onChange={value => handleInputChange(value, 'username')}/>
-                                </>
-                            }
-                            <Form.Section>
-                                <Button label='查询' type="primary" htmlType="submit" className="btn-margin-right"
-                                        onClick={refresh} loading={loading}>查询</Button>
-                            </Form.Section>
-                        </>
-                    </Form>
-                    <Spin spinning={loading}>
-                        <div style={{height: 500}}>
-                            <div id="model_pie" style={{width: '100%', minWidth: 100}}></div>
-                        </div>
-                        <div style={{height: 500}}>
-                            <div id="model_data" style={{width: '100%', minWidth: 100}}></div>
-                        </div>
-                    </Spin>
-                </Layout.Content>
-            </Layout>
-        </>
-    );
+  return (
+    <>
+      <Layout>
+        <Layout.Header>
+          <h3>数据看板</h3>
+        </Layout.Header>
+        <Layout.Content>
+          <Form ref={formRef} layout='horizontal' style={{ marginTop: 10 }}>
+            <>
+              <Form.DatePicker
+                field='start_timestamp'
+                label='起始时间'
+                style={{ width: 272 }}
+                initValue={start_timestamp}
+                value={start_timestamp}
+                type='dateTime'
+                name='start_timestamp'
+                onChange={(value) =>
+                  handleInputChange(value, 'start_timestamp')
+                }
+              />
+              <Form.DatePicker
+                field='end_timestamp'
+                fluid
+                label='结束时间'
+                style={{ width: 272 }}
+                initValue={end_timestamp}
+                value={end_timestamp}
+                type='dateTime'
+                name='end_timestamp'
+                onChange={(value) => handleInputChange(value, 'end_timestamp')}
+              />
+              <Form.Select
+                field='data_export_default_time'
+                label='时间粒度'
+                style={{ width: 176 }}
+                initValue={dataExportDefaultTime}
+                placeholder={'时间粒度'}
+                name='data_export_default_time'
+                optionList={[
+                  { label: '小时', value: 'hour' },
+                  { label: '天', value: 'day' },
+                  { label: '周', value: 'week' },
+                ]}
+                onChange={(value) =>
+                  handleInputChange(value, 'data_export_default_time')
+                }
+              ></Form.Select>
+              {isAdminUser && (
+                <>
+                  <Form.Input
+                    field='username'
+                    label='用户名称'
+                    style={{ width: 176 }}
+                    value={username}
+                    placeholder={'可选值'}
+                    name='username'
+                    onChange={(value) => handleInputChange(value, 'username')}
+                  />
+                </>
+              )}
+              <Form.Section>
+                <Button
+                  label='查询'
+                  type='primary'
+                  htmlType='submit'
+                  className='btn-margin-right'
+                  onClick={refresh}
+                  loading={loading}
+                >
+                  查询
+                </Button>
+              </Form.Section>
+            </>
+          </Form>
+          <Spin spinning={loading}>
+            <div style={{ height: 500 }}>
+              <div
+                id='model_pie'
+                style={{ width: '100%', minWidth: 100 }}
+              ></div>
+            </div>
+            <div style={{ height: 500 }}>
+              <div
+                id='model_data'
+                style={{ width: '100%', minWidth: 100 }}
+              ></div>
+            </div>
+          </Spin>
+        </Layout.Content>
+      </Layout>
+    </>
+  );
 };
 
-
 export default Detail;

+ 108 - 71
web/src/pages/Home/index.js

@@ -53,78 +53,115 @@ const Home = () => {
   }, []);
   return (
     <>
-      {
-        homePageContentLoaded && homePageContent === '' ?
-          <>
-            <Card
-              bordered={false}
-              headerLine={false}
-              title='系统状况'
-              bodyStyle={{ padding: '10px 20px' }}
-            >
-              <Row gutter={16}>
-                <Col span={12}>
-                  <Card
-                    title='系统信息'
-                    headerExtraContent={<span
-                      style={{ fontSize: '12px', color: 'var(--semi-color-text-1)' }}>系统信息总览</span>}>
-                    <p>名称:{statusState?.status?.system_name}</p>
-                    <p>版本:{statusState?.status?.version ? statusState?.status?.version : 'unknown'}</p>
-                    <p>
-                      源码:
-                      <a
-                        href='https://github.com/songquanpeng/one-api'
-                        target='_blank' rel='noreferrer'
-                      >
-                        https://github.com/songquanpeng/one-api
-                      </a>
-                    </p>
-                    <p>启动时间:{getStartTimeString()}</p>
-                  </Card>
-                </Col>
-                <Col span={12}>
-                  <Card
-                    title='系统配置'
-                    headerExtraContent={<span
-                      style={{ fontSize: '12px', color: 'var(--semi-color-text-1)' }}>系统配置总览</span>}>
-                    <p>
-                      邮箱验证:
-                      {statusState?.status?.email_verification === true ? '已启用' : '未启用'}
-                    </p>
-                    <p>
-                      GitHub 身份验证:
-                      {statusState?.status?.github_oauth === true ? '已启用' : '未启用'}
-                    </p>
-                    <p>
-                      微信身份验证:
-                      {statusState?.status?.wechat_login === true ? '已启用' : '未启用'}
-                    </p>
-                    <p>
-                      Turnstile 用户校验:
-                      {statusState?.status?.turnstile_check === true ? '已启用' : '未启用'}
-                    </p>
-                    <p>
-                      Telegram 身份验证:
-                      {statusState?.status?.telegram_oauth === true
-                        ? '已启用' : '未启用'}
-                    </p>
-                  </Card>
-                </Col>
-              </Row>
-            </Card>
-
-          </>
-          : <>
-            {
-              homePageContent.startsWith('https://') ?
-                <iframe src={homePageContent} style={{ width: '100%', height: '100vh', border: 'none' }} /> :
-                <div style={{ fontSize: 'larger' }} dangerouslySetInnerHTML={{ __html: homePageContent }}></div>
-            }
-          </>
-      }
-
+      {homePageContentLoaded && homePageContent === '' ? (
+        <>
+          <Card
+            bordered={false}
+            headerLine={false}
+            title='系统状况'
+            bodyStyle={{ padding: '10px 20px' }}
+          >
+            <Row gutter={16}>
+              <Col span={12}>
+                <Card
+                  title='系统信息'
+                  headerExtraContent={
+                    <span
+                      style={{
+                        fontSize: '12px',
+                        color: 'var(--semi-color-text-1)',
+                      }}
+                    >
+                      系统信息总览
+                    </span>
+                  }
+                >
+                  <p>名称:{statusState?.status?.system_name}</p>
+                  <p>
+                    版本:
+                    {statusState?.status?.version
+                      ? statusState?.status?.version
+                      : 'unknown'}
+                  </p>
+                  <p>
+                    源码:
+                    <a
+                      href='https://github.com/songquanpeng/one-api'
+                      target='_blank'
+                      rel='noreferrer'
+                    >
+                      https://github.com/songquanpeng/one-api
+                    </a>
+                  </p>
+                  <p>启动时间:{getStartTimeString()}</p>
+                </Card>
+              </Col>
+              <Col span={12}>
+                <Card
+                  title='系统配置'
+                  headerExtraContent={
+                    <span
+                      style={{
+                        fontSize: '12px',
+                        color: 'var(--semi-color-text-1)',
+                      }}
+                    >
+                      系统配置总览
+                    </span>
+                  }
+                >
+                  <p>
+                    邮箱验证:
+                    {statusState?.status?.email_verification === true
+                      ? '已启用'
+                      : '未启用'}
+                  </p>
+                  <p>
+                    GitHub 身份验证:
+                    {statusState?.status?.github_oauth === true
+                      ? '已启用'
+                      : '未启用'}
+                  </p>
+                  <p>
+                    微信身份验证:
+                    {statusState?.status?.wechat_login === true
+                      ? '已启用'
+                      : '未启用'}
+                  </p>
+                  <p>
+                    Turnstile 用户校验:
+                    {statusState?.status?.turnstile_check === true
+                      ? '已启用'
+                      : '未启用'}
+                  </p>
+                  <p>
+                    Telegram 身份验证:
+                    {statusState?.status?.telegram_oauth === true
+                      ? '已启用'
+                      : '未启用'}
+                  </p>
+                </Card>
+              </Col>
+            </Row>
+          </Card>
+        </>
+      ) : (
+        <>
+          {homePageContent.startsWith('https://') ? (
+            <iframe
+              src={homePageContent}
+              style={{ width: '100%', height: '100vh', border: 'none' }}
+            />
+          ) : (
+            <div
+              style={{ fontSize: 'larger' }}
+              dangerouslySetInnerHTML={{ __html: homePageContent }}
+            ></div>
+          )}
+        </>
+      )}
     </>
   );
 };
 
-export default Home;
+export default Home;

+ 59 - 30
web/src/pages/Redemption/EditRedemption.js

@@ -1,8 +1,23 @@
 import React, { useEffect, useState } from 'react';
 import { useNavigate, useParams } from 'react-router-dom';
-import { API, downloadTextAsFile, isMobile, showError, showSuccess } from '../../helpers';
+import {
+  API,
+  downloadTextAsFile,
+  isMobile,
+  showError,
+  showSuccess,
+} from '../../helpers';
 import { renderQuotaWithPrompt } from '../../helpers/render';
-import { AutoComplete, Button, Input, Modal, SideSheet, Space, Spin, Typography } from '@douyinfe/semi-ui';
+import {
+  AutoComplete,
+  Button,
+  Input,
+  Modal,
+  SideSheet,
+  Space,
+  Spin,
+  Typography,
+} from '@douyinfe/semi-ui';
 import Title from '@douyinfe/semi-ui/lib/es/typography/title';
 import { Divider } from 'semantic-ui-react';
 
@@ -15,7 +30,7 @@ const EditRedemption = (props) => {
   const originInputs = {
     name: '',
     quota: 100000,
-    count: 1
+    count: 1,
   };
   const [inputs, setInputs] = useState(originInputs);
   const { name, quota, count } = inputs;
@@ -42,11 +57,9 @@ const EditRedemption = (props) => {
 
   useEffect(() => {
     if (isEdit) {
-      loadRedemption().then(
-        () => {
-          // console.log(inputs);
-        }
-      );
+      loadRedemption().then(() => {
+        // console.log(inputs);
+      });
     } else {
       setInputs(originInputs);
     }
@@ -60,10 +73,13 @@ const EditRedemption = (props) => {
     localInputs.quota = parseInt(localInputs.quota);
     let res;
     if (isEdit) {
-      res = await API.put(`/api/redemption/`, { ...localInputs, id: parseInt(props.editingRedemption.id) });
+      res = await API.put(`/api/redemption/`, {
+        ...localInputs,
+        id: parseInt(props.editingRedemption.id),
+      });
     } else {
       res = await API.post(`/api/redemption/`, {
-        ...localInputs
+        ...localInputs,
       });
     }
     const { success, message, data } = res.data;
@@ -97,7 +113,7 @@ const EditRedemption = (props) => {
         ),
         onOk: () => {
           downloadTextAsFile(text, `${inputs.name}.txt`);
-        }
+        },
       });
     }
     setLoading(false);
@@ -107,15 +123,28 @@ const EditRedemption = (props) => {
     <>
       <SideSheet
         placement={isEdit ? 'right' : 'left'}
-        title={<Title level={3}>{isEdit ? '更新兑换码信息' : '创建新的兑换码'}</Title>}
+        title={
+          <Title level={3}>
+            {isEdit ? '更新兑换码信息' : '创建新的兑换码'}
+          </Title>
+        }
         headerStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
         bodyStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
         visible={props.visiable}
         footer={
           <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
             <Space>
-              <Button theme="solid" size={'large'} onClick={submit}>提交</Button>
-              <Button theme="solid" size={'large'} type={'tertiary'} onClick={handleCancel}>取消</Button>
+              <Button theme='solid' size={'large'} onClick={submit}>
+                提交
+              </Button>
+              <Button
+                theme='solid'
+                size={'large'}
+                type={'tertiary'}
+                onClick={handleCancel}
+              >
+                取消
+              </Button>
             </Space>
           </div>
         }
@@ -126,12 +155,12 @@ const EditRedemption = (props) => {
         <Spin spinning={loading}>
           <Input
             style={{ marginTop: 20 }}
-            label="名称"
-            name="name"
+            label='名称'
+            name='name'
             placeholder={'请输入名称'}
-            onChange={value => handleInputChange('name', value)}
+            onChange={(value) => handleInputChange('name', value)}
             value={name}
-            autoComplete="new-password"
+            autoComplete='new-password'
             required={!isEdit}
           />
           <Divider />
@@ -140,12 +169,12 @@ const EditRedemption = (props) => {
           </div>
           <AutoComplete
             style={{ marginTop: 8 }}
-            name="quota"
+            name='quota'
             placeholder={'请输入额度'}
             onChange={(value) => handleInputChange('quota', value)}
             value={quota}
-            autoComplete="new-password"
-            type="number"
+            autoComplete='new-password'
+            type='number'
             position={'bottom'}
             data={[
               { value: 500000, label: '1$' },
@@ -153,25 +182,25 @@ const EditRedemption = (props) => {
               { value: 25000000, label: '50$' },
               { value: 50000000, label: '100$' },
               { value: 250000000, label: '500$' },
-              { value: 500000000, label: '1000$' }
+              { value: 500000000, label: '1000$' },
             ]}
           />
-          {
-            !isEdit && <>
+          {!isEdit && (
+            <>
               <Divider />
               <Typography.Text>生成数量</Typography.Text>
               <Input
                 style={{ marginTop: 8 }}
-                label="生成数量"
-                name="count"
+                label='生成数量'
+                name='count'
                 placeholder={'请输入生成数量'}
-                onChange={value => handleInputChange('count', value)}
+                onChange={(value) => handleInputChange('count', value)}
                 value={count}
-                autoComplete="new-password"
-                type="number"
+                autoComplete='new-password'
+                type='number'
               />
             </>
-          }
+          )}
         </Spin>
       </SideSheet>
     </>

+ 9 - 9
web/src/pages/Redemption/index.js

@@ -1,17 +1,17 @@
 import React from 'react';
 import RedemptionsTable from '../../components/RedemptionsTable';
-import {Layout} from "@douyinfe/semi-ui";
+import { Layout } from '@douyinfe/semi-ui';
 
 const Redemption = () => (
   <>
-      <Layout>
-          <Layout.Header>
-              <h3>管理兑换码</h3>
-          </Layout.Header>
-          <Layout.Content>
-              <RedemptionsTable/>
-          </Layout.Content>
-      </Layout>
+    <Layout>
+      <Layout.Header>
+        <h3>管理兑换码</h3>
+      </Layout.Header>
+      <Layout.Content>
+        <RedemptionsTable />
+      </Layout.Content>
+    </Layout>
   </>
 );
 

+ 41 - 41
web/src/pages/Setting/index.js

@@ -1,53 +1,53 @@
 import React from 'react';
 import SystemSetting from '../../components/SystemSetting';
-import {isRoot} from '../../helpers';
+import { isRoot } from '../../helpers';
 import OtherSetting from '../../components/OtherSetting';
 import PersonalSetting from '../../components/PersonalSetting';
 import OperationSetting from '../../components/OperationSetting';
-import {Layout, TabPane, Tabs} from "@douyinfe/semi-ui";
+import { Layout, TabPane, Tabs } from '@douyinfe/semi-ui';
 
 const Setting = () => {
-    let panes = [
-        {
-            tab: '个人设置',
-            content: <PersonalSetting/>,
-            itemKey: '1'
-        }
-    ];
+  let panes = [
+    {
+      tab: '个人设置',
+      content: <PersonalSetting />,
+      itemKey: '1',
+    },
+  ];
 
-    if (isRoot()) {
-        panes.push({
-            tab: '运营设置',
-            content: <OperationSetting/>,
-            itemKey: '2'
-        });
-        panes.push({
-            tab: '系统设置',
-            content: <SystemSetting/>,
-            itemKey: '3'
-        });
-        panes.push({
-            tab: '其他设置',
-            content: <OtherSetting/>,
-            itemKey: '4'
-        });
-    }
+  if (isRoot()) {
+    panes.push({
+      tab: '运营设置',
+      content: <OperationSetting />,
+      itemKey: '2',
+    });
+    panes.push({
+      tab: '系统设置',
+      content: <SystemSetting />,
+      itemKey: '3',
+    });
+    panes.push({
+      tab: '其他设置',
+      content: <OtherSetting />,
+      itemKey: '4',
+    });
+  }
 
-    return (
-        <div>
-            <Layout>
-                <Layout.Content>
-                    <Tabs type="line" defaultActiveKey="1">
-                        {panes.map(pane => (
-                            <TabPane itemKey={pane.itemKey} tab={pane.tab}>
-                                {pane.content}
-                            </TabPane>
-                        ))}
-                    </Tabs>
-                </Layout.Content>
-            </Layout>
-        </div>
-    );
+  return (
+    <div>
+      <Layout>
+        <Layout.Content>
+          <Tabs type='line' defaultActiveKey='1'>
+            {panes.map((pane) => (
+              <TabPane itemKey={pane.itemKey} tab={pane.tab}>
+                {pane.content}
+              </TabPane>
+            ))}
+          </Tabs>
+        </Layout.Content>
+      </Layout>
+    </div>
+  );
 };
 
 export default Setting;

+ 130 - 68
web/src/pages/Token/EditToken.js

@@ -1,19 +1,25 @@
 import React, { useEffect, useState } from 'react';
 import { useNavigate } from 'react-router-dom';
-import { API, isMobile, showError, showSuccess, timestamp2string } from '../../helpers';
+import {
+  API,
+  isMobile,
+  showError,
+  showSuccess,
+  timestamp2string,
+} from '../../helpers';
 import { renderQuotaWithPrompt } from '../../helpers/render';
 import {
-    AutoComplete,
-    Banner,
-    Button,
-    Checkbox,
-    DatePicker,
-    Input,
-    Select,
-    SideSheet,
-    Space,
-    Spin,
-    Typography
+  AutoComplete,
+  Banner,
+  Button,
+  Checkbox,
+  DatePicker,
+  Input,
+  Select,
+  SideSheet,
+  Space,
+  Spin,
+  Typography,
 } from '@douyinfe/semi-ui';
 import Title from '@douyinfe/semi-ui/lib/es/typography/title';
 import { Divider } from 'semantic-ui-react';
@@ -27,10 +33,17 @@ const EditToken = (props) => {
     expired_time: -1,
     unlimited_quota: false,
     model_limits_enabled: false,
-    model_limits: []
+    model_limits: [],
   };
   const [inputs, setInputs] = useState(originInputs);
-  const { name, remain_quota, expired_time, unlimited_quota, model_limits_enabled, model_limits } = inputs;
+  const {
+    name,
+    remain_quota,
+    expired_time,
+    unlimited_quota,
+    model_limits_enabled,
+    model_limits,
+  } = inputs;
   // const [visible, setVisible] = useState(false);
   const [models, setModels] = useState({});
   const navigate = useNavigate();
@@ -65,7 +78,7 @@ const EditToken = (props) => {
     if (success) {
       let localModelOptions = data.map((model) => ({
         label: model,
-        value: model
+        value: model,
       }));
       setModels(localModelOptions);
     } else {
@@ -100,11 +113,9 @@ const EditToken = (props) => {
     if (!isEdit) {
       setInputs(originInputs);
     } else {
-      loadToken().then(
-        () => {
-          // console.log(inputs);
-        }
-      );
+      loadToken().then(() => {
+        // console.log(inputs);
+      });
     }
     loadModels();
   }, [isEdit]);
@@ -123,10 +134,13 @@ const EditToken = (props) => {
 
   // 生成一个随机的四位字母数字字符串
   const generateRandomSuffix = () => {
-    const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+    const characters =
+      'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
     let result = '';
     for (let i = 0; i < 6; i++) {
-      result += characters.charAt(Math.floor(Math.random() * characters.length));
+      result += characters.charAt(
+        Math.floor(Math.random() * characters.length),
+      );
     }
     return result;
   };
@@ -147,7 +161,10 @@ const EditToken = (props) => {
         localInputs.expired_time = Math.ceil(time / 1000);
       }
       localInputs.model_limits = localInputs.model_limits.join(',');
-      let res = await API.put(`/api/token/`, { ...localInputs, id: parseInt(props.editingToken.id) });
+      let res = await API.put(`/api/token/`, {
+        ...localInputs,
+        id: parseInt(props.editingToken.id),
+      });
       const { success, message } = res.data;
       if (success) {
         showSuccess('令牌更新成功!');
@@ -189,7 +206,9 @@ const EditToken = (props) => {
       }
 
       if (successCount > 0) {
-        showSuccess(`${successCount}个令牌创建成功,请在列表页面点击复制获取令牌!`);
+        showSuccess(
+          `${successCount}个令牌创建成功,请在列表页面点击复制获取令牌!`,
+        );
         props.refresh();
         props.handleClose();
       }
@@ -199,20 +218,30 @@ const EditToken = (props) => {
     setTokenCount(1); // 重置数量为默认值
   };
 
-
   return (
     <>
       <SideSheet
         placement={isEdit ? 'right' : 'left'}
-        title={<Title level={3}>{isEdit ? '更新令牌信息' : '创建新的令牌'}</Title>}
+        title={
+          <Title level={3}>{isEdit ? '更新令牌信息' : '创建新的令牌'}</Title>
+        }
         headerStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
         bodyStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
         visible={props.visiable}
         footer={
           <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
             <Space>
-              <Button theme="solid" size={'large'} onClick={submit}>提交</Button>
-              <Button theme="solid" size={'large'} type={'tertiary'} onClick={handleCancel}>取消</Button>
+              <Button theme='solid' size={'large'} onClick={submit}>
+                提交
+              </Button>
+              <Button
+                theme='solid'
+                size={'large'}
+                type={'tertiary'}
+                onClick={handleCancel}
+              >
+                取消
+              </Button>
             </Space>
           </div>
         }
@@ -223,55 +252,79 @@ const EditToken = (props) => {
         <Spin spinning={loading}>
           <Input
             style={{ marginTop: 20 }}
-            label="名称"
-            name="name"
+            label='名称'
+            name='name'
             placeholder={'请输入名称'}
             onChange={(value) => handleInputChange('name', value)}
             value={name}
-            autoComplete="new-password"
+            autoComplete='new-password'
             required={!isEdit}
           />
           <Divider />
           <DatePicker
-            label="过期时间"
-            name="expired_time"
+            label='过期时间'
+            name='expired_time'
             placeholder={'请选择过期时间'}
             onChange={(value) => handleInputChange('expired_time', value)}
             value={expired_time}
-            autoComplete="new-password"
-            type="dateTime"
+            autoComplete='new-password'
+            type='dateTime'
           />
           <div style={{ marginTop: 20 }}>
             <Space>
-              <Button type={'tertiary'} onClick={() => {
-                setExpiredTime(0, 0, 0, 0);
-              }}>永不过期</Button>
-              <Button type={'tertiary'} onClick={() => {
-                setExpiredTime(0, 0, 1, 0);
-              }}>一小时</Button>
-              <Button type={'tertiary'} onClick={() => {
-                setExpiredTime(1, 0, 0, 0);
-              }}>一个月</Button>
-              <Button type={'tertiary'} onClick={() => {
-                setExpiredTime(0, 1, 0, 0);
-              }}>一天</Button>
+              <Button
+                type={'tertiary'}
+                onClick={() => {
+                  setExpiredTime(0, 0, 0, 0);
+                }}
+              >
+                永不过期
+              </Button>
+              <Button
+                type={'tertiary'}
+                onClick={() => {
+                  setExpiredTime(0, 0, 1, 0);
+                }}
+              >
+                一小时
+              </Button>
+              <Button
+                type={'tertiary'}
+                onClick={() => {
+                  setExpiredTime(1, 0, 0, 0);
+                }}
+              >
+                一个月
+              </Button>
+              <Button
+                type={'tertiary'}
+                onClick={() => {
+                  setExpiredTime(0, 1, 0, 0);
+                }}
+              >
+                一天
+              </Button>
             </Space>
           </div>
 
           <Divider />
-          <Banner type={'warning'}
-                  description={'注意,令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制。'}></Banner>
+          <Banner
+            type={'warning'}
+            description={
+              '注意,令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制。'
+            }
+          ></Banner>
           <div style={{ marginTop: 20 }}>
             <Typography.Text>{`额度${renderQuotaWithPrompt(remain_quota)}`}</Typography.Text>
           </div>
           <AutoComplete
             style={{ marginTop: 8 }}
-            name="remain_quota"
+            name='remain_quota'
             placeholder={'请输入额度'}
             onChange={(value) => handleInputChange('remain_quota', value)}
             value={remain_quota}
-            autoComplete="new-password"
-            type="number"
+            autoComplete='new-password'
+            type='number'
             // position={'top'}
             data={[
               { value: 500000, label: '1$' },
@@ -279,7 +332,7 @@ const EditToken = (props) => {
               { value: 25000000, label: '50$' },
               { value: 50000000, label: '100$' },
               { value: 250000000, label: '500$' },
-              { value: 500000000, label: '1000$' }
+              { value: 500000000, label: '1000$' },
             ]}
             disabled={unlimited_quota}
           />
@@ -291,18 +344,18 @@ const EditToken = (props) => {
               </div>
               <AutoComplete
                 style={{ marginTop: 8 }}
-                label="数量"
+                label='数量'
                 placeholder={'请选择或输入创建令牌的数量'}
                 onChange={(value) => handleTokenCountChange(value)}
                 onSelect={(value) => handleTokenCountChange(value)}
                 value={tokenCount.toString()}
-                autoComplete="off"
-                type="number"
+                autoComplete='off'
+                type='number'
                 data={[
                   { value: 10, label: '10个' },
                   { value: 20, label: '20个' },
                   { value: 30, label: '30个' },
-                  { value: 100, label: '100个' }
+                  { value: 100, label: '100个' },
                 ]}
                 disabled={unlimited_quota}
               />
@@ -310,35 +363,44 @@ const EditToken = (props) => {
           )}
 
           <div>
-            <Button style={{ marginTop: 8 }} type={'warning'} onClick={() => {
-              setUnlimitedQuota();
-            }}>{unlimited_quota ? '取消无限额度' : '设为无限额度'}</Button>
+            <Button
+              style={{ marginTop: 8 }}
+              type={'warning'}
+              onClick={() => {
+                setUnlimitedQuota();
+              }}
+            >
+              {unlimited_quota ? '取消无限额度' : '设为无限额度'}
+            </Button>
           </div>
           <Divider />
           <div style={{ marginTop: 10, display: 'flex' }}>
             <Space>
               <Checkbox
-                name="model_limits_enabled"
+                name='model_limits_enabled'
                 checked={model_limits_enabled}
-                onChange={(e) => handleInputChange('model_limits_enabled', e.target.checked)}
-              >
-              </Checkbox>
-              <Typography.Text>启用模型限制(非必要,不建议启用)</Typography.Text>
+                onChange={(e) =>
+                  handleInputChange('model_limits_enabled', e.target.checked)
+                }
+              ></Checkbox>
+              <Typography.Text>
+                启用模型限制(非必要,不建议启用)
+              </Typography.Text>
             </Space>
           </div>
 
           <Select
             style={{ marginTop: 8 }}
             placeholder={'请选择该渠道所支持的模型'}
-            name="models"
+            name='models'
             required
             multiple
             selection
-            onChange={value => {
+            onChange={(value) => {
               handleInputChange('model_limits', value);
             }}
             value={inputs.model_limits}
-            autoComplete="new-password"
+            autoComplete='new-password'
             optionList={models}
             disabled={!model_limits_enabled}
           />

+ 3 - 3
web/src/pages/Token/index.js

@@ -1,14 +1,14 @@
 import React from 'react';
 import TokensTable from '../../components/TokensTable';
-import {Layout} from "@douyinfe/semi-ui";
+import { Layout } from '@douyinfe/semi-ui';
 const Token = () => (
   <>
     <Layout>
       <Layout.Header>
-          <h3>我的令牌</h3>
+        <h3>我的令牌</h3>
       </Layout.Header>
       <Layout.Content>
-          <TokensTable/>
+        <TokensTable />
       </Layout.Content>
     </Layout>
   </>

+ 314 - 290
web/src/pages/TopUp/index.js

@@ -1,314 +1,338 @@
-import React, {useEffect, useState} from 'react';
-import {API, isMobile, showError, showInfo, showSuccess} from '../../helpers';
-import {renderNumber, renderQuota} from '../../helpers/render';
-import {Col, Layout, Row, Typography, Card, Button, Form, Divider, Space, Modal} from "@douyinfe/semi-ui";
-import Title from "@douyinfe/semi-ui/lib/es/typography/title";
+import React, { useEffect, useState } from 'react';
+import { API, isMobile, showError, showInfo, showSuccess } from '../../helpers';
+import { renderNumber, renderQuota } from '../../helpers/render';
+import {
+  Col,
+  Layout,
+  Row,
+  Typography,
+  Card,
+  Button,
+  Form,
+  Divider,
+  Space,
+  Modal,
+} from '@douyinfe/semi-ui';
+import Title from '@douyinfe/semi-ui/lib/es/typography/title';
 import Text from '@douyinfe/semi-ui/lib/es/typography/text';
 import { Link } from 'react-router-dom';
 
 const TopUp = () => {
-    const [redemptionCode, setRedemptionCode] = useState('');
-    const [topUpCode, setTopUpCode] = useState('');
-    const [topUpCount, setTopUpCount] = useState(10);
-    const [minTopupCount, setMinTopUpCount] = useState(1);
-    const [amount, setAmount] = useState(0.0);
-    const [minTopUp, setMinTopUp] = useState(1);
-    const [topUpLink, setTopUpLink] = useState('');
-    const [enableOnlineTopUp, setEnableOnlineTopUp] = useState(false);
-    const [userQuota, setUserQuota] = useState(0);
-    const [isSubmitting, setIsSubmitting] = useState(false);
-    const [open, setOpen] = useState(false);
-    const [payWay, setPayWay] = useState('');
+  const [redemptionCode, setRedemptionCode] = useState('');
+  const [topUpCode, setTopUpCode] = useState('');
+  const [topUpCount, setTopUpCount] = useState(10);
+  const [minTopupCount, setMinTopUpCount] = useState(1);
+  const [amount, setAmount] = useState(0.0);
+  const [minTopUp, setMinTopUp] = useState(1);
+  const [topUpLink, setTopUpLink] = useState('');
+  const [enableOnlineTopUp, setEnableOnlineTopUp] = useState(false);
+  const [userQuota, setUserQuota] = useState(0);
+  const [isSubmitting, setIsSubmitting] = useState(false);
+  const [open, setOpen] = useState(false);
+  const [payWay, setPayWay] = useState('');
 
-    const topUp = async () => {
-        if (redemptionCode === '') {
-            showInfo('请输入兑换码!')
-            return;
-        }
-        setIsSubmitting(true);
-        try {
-            const res = await API.post('/api/user/topup', {
-                key: redemptionCode
-            });
-            const {success, message, data} = res.data;
-            if (success) {
-                showSuccess('兑换成功!');
-                Modal.success({title: '兑换成功!', content: '成功兑换额度:' + renderQuota(data), centered: true});
-                setUserQuota((quota) => {
-                    return quota + data;
-                });
-                setRedemptionCode('');
-            } else {
-                showError(message);
-            }
-        } catch (err) {
-            showError('请求失败');
-        } finally {
-            setIsSubmitting(false);
-        }
-    };
-
-    const openTopUpLink = () => {
-        if (!topUpLink) {
-            showError('超级管理员未设置充值链接!');
-            return;
-        }
-        window.open(topUpLink, '_blank');
-    };
-
-    const preTopUp = async (payment) => {
-        if (!enableOnlineTopUp) {
-            showError('管理员未开启在线充值!');
-            return;
-        }
-        if (amount === 0) {
-            await getAmount();
-        }
-        if (topUpCount < minTopUp) {
-            showInfo('充值数量不能小于' + minTopUp);
-            return;
-        }
-        setPayWay(payment)
-        setOpen(true);
+  const topUp = async () => {
+    if (redemptionCode === '') {
+      showInfo('请输入兑换码!');
+      return;
+    }
+    setIsSubmitting(true);
+    try {
+      const res = await API.post('/api/user/topup', {
+        key: redemptionCode,
+      });
+      const { success, message, data } = res.data;
+      if (success) {
+        showSuccess('兑换成功!');
+        Modal.success({
+          title: '兑换成功!',
+          content: '成功兑换额度:' + renderQuota(data),
+          centered: true,
+        });
+        setUserQuota((quota) => {
+          return quota + data;
+        });
+        setRedemptionCode('');
+      } else {
+        showError(message);
+      }
+    } catch (err) {
+      showError('请求失败');
+    } finally {
+      setIsSubmitting(false);
     }
+  };
 
-    const onlineTopUp = async () => {
-        if (amount === 0) {
-            await getAmount();
-        }
-        if (topUpCount < minTopUp) {
-            showInfo('充值数量不能小于' + minTopUp);
-            return;
-        }
-        setOpen(false);
-        try {
-            const res = await API.post('/api/user/pay', {
-                amount: parseInt(topUpCount),
-                top_up_code: topUpCode,
-                payment_method: payWay
-            });
-            if (res !== undefined) {
-                const {message, data} = res.data;
-                // showInfo(message);
-                if (message === 'success') {
+  const openTopUpLink = () => {
+    if (!topUpLink) {
+      showError('超级管理员未设置充值链接!');
+      return;
+    }
+    window.open(topUpLink, '_blank');
+  };
 
-                    let params = data
-                    let url = res.data.url
-                    let form = document.createElement('form')
-                    form.action = url
-                    form.method = 'POST'
-                    // 判断是否为safari浏览器
-                    let isSafari = navigator.userAgent.indexOf("Safari") > -1 && navigator.userAgent.indexOf("Chrome") < 1;
-                    if (!isSafari) {
-                        form.target = '_blank'
-                    }
-                    for (let key in params) {
-                        let input = document.createElement('input')
-                        input.type = 'hidden'
-                        input.name = key
-                        input.value = params[key]
-                        form.appendChild(input)
-                    }
-                    document.body.appendChild(form)
-                    form.submit()
-                    document.body.removeChild(form)
-                } else {
-                    showError(data);
-                    // setTopUpCount(parseInt(res.data.count));
-                    // setAmount(parseInt(data));
-                }
-            } else {
-                showError(res);
-            }
-        } catch (err) {
-            console.log(err);
-        } finally {
-        }
+  const preTopUp = async (payment) => {
+    if (!enableOnlineTopUp) {
+      showError('管理员未开启在线充值!');
+      return;
+    }
+    if (amount === 0) {
+      await getAmount();
     }
+    if (topUpCount < minTopUp) {
+      showInfo('充值数量不能小于' + minTopUp);
+      return;
+    }
+    setPayWay(payment);
+    setOpen(true);
+  };
 
-    const getUserQuota = async () => {
-        let res = await API.get(`/api/user/self`);
-        const {success, message, data} = res.data;
-        if (success) {
-            setUserQuota(data.quota);
+  const onlineTopUp = async () => {
+    if (amount === 0) {
+      await getAmount();
+    }
+    if (topUpCount < minTopUp) {
+      showInfo('充值数量不能小于' + minTopUp);
+      return;
+    }
+    setOpen(false);
+    try {
+      const res = await API.post('/api/user/pay', {
+        amount: parseInt(topUpCount),
+        top_up_code: topUpCode,
+        payment_method: payWay,
+      });
+      if (res !== undefined) {
+        const { message, data } = res.data;
+        // showInfo(message);
+        if (message === 'success') {
+          let params = data;
+          let url = res.data.url;
+          let form = document.createElement('form');
+          form.action = url;
+          form.method = 'POST';
+          // 判断是否为safari浏览器
+          let isSafari =
+            navigator.userAgent.indexOf('Safari') > -1 &&
+            navigator.userAgent.indexOf('Chrome') < 1;
+          if (!isSafari) {
+            form.target = '_blank';
+          }
+          for (let key in params) {
+            let input = document.createElement('input');
+            input.type = 'hidden';
+            input.name = key;
+            input.value = params[key];
+            form.appendChild(input);
+          }
+          document.body.appendChild(form);
+          form.submit();
+          document.body.removeChild(form);
         } else {
-            showError(message);
+          showError(data);
+          // setTopUpCount(parseInt(res.data.count));
+          // setAmount(parseInt(data));
         }
+      } else {
+        showError(res);
+      }
+    } catch (err) {
+      console.log(err);
+    } finally {
     }
+  };
 
-    useEffect(() => {
-        let status = localStorage.getItem('status');
-        if (status) {
-            status = JSON.parse(status);
-            if (status.top_up_link) {
-                setTopUpLink(status.top_up_link);
-            }
-            if (status.min_topup) {
-                setMinTopUp(status.min_topup);
-            }
-            if (status.enable_online_topup) {
-                setEnableOnlineTopUp(status.enable_online_topup);
-            }
-        }
-        getUserQuota().then();
-    }, []);
+  const getUserQuota = async () => {
+    let res = await API.get(`/api/user/self`);
+    const { success, message, data } = res.data;
+    if (success) {
+      setUserQuota(data.quota);
+    } else {
+      showError(message);
+    }
+  };
 
-    const renderAmount = () => {
-        // console.log(amount);
-        return amount + '元';
+  useEffect(() => {
+    let status = localStorage.getItem('status');
+    if (status) {
+      status = JSON.parse(status);
+      if (status.top_up_link) {
+        setTopUpLink(status.top_up_link);
+      }
+      if (status.min_topup) {
+        setMinTopUp(status.min_topup);
+      }
+      if (status.enable_online_topup) {
+        setEnableOnlineTopUp(status.enable_online_topup);
+      }
     }
+    getUserQuota().then();
+  }, []);
 
-    const getAmount = async (value) => {
-        if (value === undefined) {
-            value = topUpCount;
-        }
-        try {
-            const res = await API.post('/api/user/amount', {
-                amount: parseFloat(value),
-                top_up_code: topUpCode
-            });
-            if (res !== undefined) {
-                const {message, data} = res.data;
-                // showInfo(message);
-                if (message === 'success') {
-                    setAmount(parseFloat(data));
-                } else {
-                    showError(data);
-                    // setTopUpCount(parseInt(res.data.count));
-                    // setAmount(parseInt(data));
-                }
-            } else {
-                showError(res);
-            }
-        } catch (err) {
-            console.log(err);
-        } finally {
+  const renderAmount = () => {
+    // console.log(amount);
+    return amount + '元';
+  };
+
+  const getAmount = async (value) => {
+    if (value === undefined) {
+      value = topUpCount;
+    }
+    try {
+      const res = await API.post('/api/user/amount', {
+        amount: parseFloat(value),
+        top_up_code: topUpCode,
+      });
+      if (res !== undefined) {
+        const { message, data } = res.data;
+        // showInfo(message);
+        if (message === 'success') {
+          setAmount(parseFloat(data));
+        } else {
+          showError(data);
+          // setTopUpCount(parseInt(res.data.count));
+          // setAmount(parseInt(data));
         }
+      } else {
+        showError(res);
+      }
+    } catch (err) {
+      console.log(err);
+    } finally {
     }
+  };
 
-    const handleCancel = () => {
-        setOpen(false);
-    }
+  const handleCancel = () => {
+    setOpen(false);
+  };
 
-    return (
-        <div>
-            <Layout>
-                <Layout.Header>
-                    <h3>我的钱包</h3>
-                </Layout.Header>
-                <Layout.Content>
-                    <Modal
-                        title="确定要充值吗"
-                        visible={open}
-                        onOk={onlineTopUp}
-                        onCancel={handleCancel}
-                        maskClosable={false}
-                        size={'small'}
-                        centered={true}
+  return (
+    <div>
+      <Layout>
+        <Layout.Header>
+          <h3>我的钱包</h3>
+        </Layout.Header>
+        <Layout.Content>
+          <Modal
+            title='确定要充值吗'
+            visible={open}
+            onOk={onlineTopUp}
+            onCancel={handleCancel}
+            maskClosable={false}
+            size={'small'}
+            centered={true}
+          >
+            <p>充值数量:{topUpCount}$</p>
+            <p>实付金额:{renderAmount()}</p>
+            <p>是否确认充值?</p>
+          </Modal>
+          <div
+            style={{ marginTop: 20, display: 'flex', justifyContent: 'center' }}
+          >
+            <Card style={{ width: '500px', padding: '20px' }}>
+              <Title level={3} style={{ textAlign: 'center' }}>
+                余额 {renderQuota(userQuota)}
+              </Title>
+              <div style={{ marginTop: 20 }}>
+                <Divider>兑换余额</Divider>
+                <Form>
+                  <Form.Input
+                    field={'redemptionCode'}
+                    label={'兑换码'}
+                    placeholder='兑换码'
+                    name='redemptionCode'
+                    value={redemptionCode}
+                    onChange={(value) => {
+                      setRedemptionCode(value);
+                    }}
+                  />
+                  <Space>
+                    {topUpLink ? (
+                      <Button
+                        type={'primary'}
+                        theme={'solid'}
+                        onClick={openTopUpLink}
+                      >
+                        获取兑换码
+                      </Button>
+                    ) : null}
+                    <Button
+                      type={'warning'}
+                      theme={'solid'}
+                      onClick={topUp}
+                      disabled={isSubmitting}
                     >
-                        <p>充值数量:{topUpCount}$</p>
-                        <p>实付金额:{renderAmount()}</p>
-                        <p>是否确认充值?</p>
-                    </Modal>
-                    <div style={{marginTop: 20, display: 'flex', justifyContent: 'center'}}>
-                        <Card
-                            style={{width: '500px', padding: '20px'}}
-                        >
-                            <Title level={3} style={{textAlign: 'center'}}>余额 {renderQuota(userQuota)}</Title>
-                            <div style={{marginTop: 20}}>
-                                <Divider>
-                                    兑换余额
-                                </Divider>
-                                <Form>
-                                    <Form.Input
-                                        field={'redemptionCode'}
-                                        label={'兑换码'}
-                                        placeholder='兑换码'
-                                        name='redemptionCode'
-                                        value={redemptionCode}
-                                        onChange={(value) => {
-                                            setRedemptionCode(value);
-                                        }}
-                                    />
-                                    <Space>
-                                        {
-                                            topUpLink ?
-                                                <Button type={'primary'} theme={'solid'} onClick={openTopUpLink}>
-                                                    获取兑换码
-                                                </Button> : null
-                                        }
-                                        <Button type={"warning"} theme={'solid'} onClick={topUp}
-                                                disabled={isSubmitting}>
-                                            {isSubmitting ? '兑换中...' : '兑换'}
-                                        </Button>
-                                    </Space>
-                                </Form>
-                            </div>
-                            <div style={{marginTop: 20}}>
-                                <Divider>
-                                    在线充值
-                                </Divider>
-                                <Form>
-                                    <Form.Input
-                                        disabled={!enableOnlineTopUp}
-                                        field={'redemptionCount'}
-                                        label={'实付金额:' + renderAmount()}
-                                        placeholder={'充值数量,最低' + minTopUp + '$'}
-                                        name='redemptionCount'
-                                        type={'number'}
-                                        value={topUpCount}
-                                        suffix={'$'}
-                                        min={minTopUp}
-                                        defaultValue={minTopUp}
-                                        max={100000}
-                                        onChange={async (value) => {
-                                            if (value < 1) {
-                                                value = 1;
-                                            }
-                                            if (value > 100000) {
-                                                value = 100000;
-                                            }
-                                            setTopUpCount(value);
-                                            await getAmount(value);
-                                        }}
-                                    />
-                                    <Space>
-                                        <Button type={'primary'} theme={'solid'} onClick={
-                                            async () => {
-                                                preTopUp('zfb')
-                                            }
-                                        }>
-                                            支付宝
-                                        </Button>
-                                        <Button style={{backgroundColor: 'rgba(var(--semi-green-5), 1)'}}
-                                                type={'primary'}
-                                                theme={'solid'} onClick={
-                                            async () => {
-                                                preTopUp('wx')
-                                            }
-                                        }>
-                                            微信
-                                        </Button>
-                                    </Space>
-                                </Form>
-                            </div>
-                            {/*<div style={{ display: 'flex', justifyContent: 'right' }}>*/}
-                            {/*    <Text>*/}
-                            {/*        <Link onClick={*/}
-                            {/*            async () => {*/}
-                            {/*                window.location.href = '/topup/history'*/}
-                            {/*            }*/}
-                            {/*        }>充值记录</Link>*/}
-                            {/*    </Text>*/}
-                            {/*</div>*/}
-                        </Card>
-                    </div>
-
-                </Layout.Content>
-            </Layout>
-        </div>
-
-    );
+                      {isSubmitting ? '兑换中...' : '兑换'}
+                    </Button>
+                  </Space>
+                </Form>
+              </div>
+              <div style={{ marginTop: 20 }}>
+                <Divider>在线充值</Divider>
+                <Form>
+                  <Form.Input
+                    disabled={!enableOnlineTopUp}
+                    field={'redemptionCount'}
+                    label={'实付金额:' + renderAmount()}
+                    placeholder={'充值数量,最低' + minTopUp + '$'}
+                    name='redemptionCount'
+                    type={'number'}
+                    value={topUpCount}
+                    suffix={'$'}
+                    min={minTopUp}
+                    defaultValue={minTopUp}
+                    max={100000}
+                    onChange={async (value) => {
+                      if (value < 1) {
+                        value = 1;
+                      }
+                      if (value > 100000) {
+                        value = 100000;
+                      }
+                      setTopUpCount(value);
+                      await getAmount(value);
+                    }}
+                  />
+                  <Space>
+                    <Button
+                      type={'primary'}
+                      theme={'solid'}
+                      onClick={async () => {
+                        preTopUp('zfb');
+                      }}
+                    >
+                      支付宝
+                    </Button>
+                    <Button
+                      style={{
+                        backgroundColor: 'rgba(var(--semi-green-5), 1)',
+                      }}
+                      type={'primary'}
+                      theme={'solid'}
+                      onClick={async () => {
+                        preTopUp('wx');
+                      }}
+                    >
+                      微信
+                    </Button>
+                  </Space>
+                </Form>
+              </div>
+              {/*<div style={{ display: 'flex', justifyContent: 'right' }}>*/}
+              {/*    <Text>*/}
+              {/*        <Link onClick={*/}
+              {/*            async () => {*/}
+              {/*                window.location.href = '/topup/history'*/}
+              {/*            }*/}
+              {/*        }>充值记录</Link>*/}
+              {/*    </Text>*/}
+              {/*</div>*/}
+            </Card>
+          </div>
+        </Layout.Content>
+      </Layout>
+    </div>
+  );
 };
 
-export default TopUp;
+export default TopUp;

+ 24 - 15
web/src/pages/User/AddUser.js

@@ -7,7 +7,7 @@ const AddUser = (props) => {
   const originInputs = {
     username: '',
     display_name: '',
-    password: ''
+    password: '',
   };
   const [inputs, setInputs] = useState(originInputs);
   const [loading, setLoading] = useState(false);
@@ -48,8 +48,17 @@ const AddUser = (props) => {
         footer={
           <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
             <Space>
-              <Button theme="solid" size={'large'} onClick={submit}>提交</Button>
-              <Button theme="solid" size={'large'} type={'tertiary'} onClick={handleCancel}>取消</Button>
+              <Button theme='solid' size={'large'} onClick={submit}>
+                提交
+              </Button>
+              <Button
+                theme='solid'
+                size={'large'}
+                type={'tertiary'}
+                onClick={handleCancel}
+              >
+                取消
+              </Button>
             </Space>
           </div>
         }
@@ -60,34 +69,34 @@ const AddUser = (props) => {
         <Spin spinning={loading}>
           <Input
             style={{ marginTop: 20 }}
-            label="用户名"
-            name="username"
+            label='用户名'
+            name='username'
             addonBefore={'用户名'}
             placeholder={'请输入用户名'}
-            onChange={value => handleInputChange('username', value)}
+            onChange={(value) => handleInputChange('username', value)}
             value={username}
-            autoComplete="off"
+            autoComplete='off'
           />
           <Input
             style={{ marginTop: 20 }}
             addonBefore={'显示名'}
-            label="显示名称"
-            name="display_name"
-            autoComplete="off"
+            label='显示名称'
+            name='display_name'
+            autoComplete='off'
             placeholder={'请输入显示名称'}
-            onChange={value => handleInputChange('display_name', value)}
+            onChange={(value) => handleInputChange('display_name', value)}
             value={display_name}
           />
           <Input
             style={{ marginTop: 20 }}
-            label="密 码"
-            name="password"
+            label='密 码'
+            name='password'
             type={'password'}
             addonBefore={'密码'}
             placeholder={'请输入密码'}
-            onChange={value => handleInputChange('password', value)}
+            onChange={(value) => handleInputChange('password', value)}
             value={password}
-            autoComplete="off"
+            autoComplete='off'
           />
         </Spin>
       </SideSheet>

+ 72 - 43
web/src/pages/User/EditUser.js

@@ -3,7 +3,16 @@ import { useNavigate } from 'react-router-dom';
 import { API, isMobile, showError, showSuccess } from '../../helpers';
 import { renderQuotaWithPrompt } from '../../helpers/render';
 import Title from '@douyinfe/semi-ui/lib/es/typography/title';
-import { Button, Divider, Input, Select, SideSheet, Space, Spin, Typography } from '@douyinfe/semi-ui';
+import {
+  Button,
+  Divider,
+  Input,
+  Select,
+  SideSheet,
+  Space,
+  Spin,
+  Typography,
+} from '@douyinfe/semi-ui';
 
 const EditUser = (props) => {
   const userId = props.editingUser.id;
@@ -16,21 +25,32 @@ const EditUser = (props) => {
     wechat_id: '',
     email: '',
     quota: 0,
-    group: 'default'
+    group: 'default',
   });
   const [groupOptions, setGroupOptions] = useState([]);
-  const { username, display_name, password, github_id, wechat_id, telegram_id, email, quota, group } =
-    inputs;
+  const {
+    username,
+    display_name,
+    password,
+    github_id,
+    wechat_id,
+    telegram_id,
+    email,
+    quota,
+    group,
+  } = inputs;
   const handleInputChange = (name, value) => {
     setInputs((inputs) => ({ ...inputs, [name]: value }));
   };
   const fetchGroups = async () => {
     try {
       let res = await API.get(`/api/group/`);
-      setGroupOptions(res.data.data.map((group) => ({
-        label: group,
-        value: group
-      })));
+      setGroupOptions(
+        res.data.data.map((group) => ({
+          label: group,
+          value: group,
+        })),
+      );
     } catch (error) {
       showError(error.message);
     }
@@ -98,8 +118,17 @@ const EditUser = (props) => {
         footer={
           <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
             <Space>
-              <Button theme="solid" size={'large'} onClick={submit}>提交</Button>
-              <Button theme="solid" size={'large'} type={'tertiary'} onClick={handleCancel}>取消</Button>
+              <Button theme='solid' size={'large'} onClick={submit}>
+                提交
+              </Button>
+              <Button
+                theme='solid'
+                size={'large'}
+                type={'tertiary'}
+                onClick={handleCancel}
+              >
+                取消
+              </Button>
             </Space>
           </div>
         }
@@ -112,103 +141,103 @@ const EditUser = (props) => {
             <Typography.Text>用户名</Typography.Text>
           </div>
           <Input
-            label="用户名"
-            name="username"
+            label='用户名'
+            name='username'
             placeholder={'请输入新的用户名'}
-            onChange={value => handleInputChange('username', value)}
+            onChange={(value) => handleInputChange('username', value)}
             value={username}
-            autoComplete="new-password"
+            autoComplete='new-password'
           />
           <div style={{ marginTop: 20 }}>
             <Typography.Text>密码</Typography.Text>
           </div>
           <Input
-            label="密码"
-            name="password"
+            label='密码'
+            name='password'
             type={'password'}
             placeholder={'请输入新的密码,最短 8 位'}
-            onChange={value => handleInputChange('password', value)}
+            onChange={(value) => handleInputChange('password', value)}
             value={password}
-            autoComplete="new-password"
+            autoComplete='new-password'
           />
           <div style={{ marginTop: 20 }}>
             <Typography.Text>显示名称</Typography.Text>
           </div>
           <Input
-            label="显示名称"
-            name="display_name"
+            label='显示名称'
+            name='display_name'
             placeholder={'请输入新的显示名称'}
-            onChange={value => handleInputChange('display_name', value)}
+            onChange={(value) => handleInputChange('display_name', value)}
             value={display_name}
-            autoComplete="new-password"
+            autoComplete='new-password'
           />
-          {
-            userId && <>
+          {userId && (
+            <>
               <div style={{ marginTop: 20 }}>
                 <Typography.Text>分组</Typography.Text>
               </div>
               <Select
                 placeholder={'请选择分组'}
-                name="group"
+                name='group'
                 fluid
                 search
                 selection
                 allowAdditions
                 additionLabel={'请在系统设置页面编辑分组倍率以添加新的分组:'}
-                onChange={value => handleInputChange('group', value)}
+                onChange={(value) => handleInputChange('group', value)}
                 value={inputs.group}
-                autoComplete="new-password"
+                autoComplete='new-password'
                 optionList={groupOptions}
               />
               <div style={{ marginTop: 20 }}>
                 <Typography.Text>{`剩余额度${renderQuotaWithPrompt(quota)}`}</Typography.Text>
               </div>
               <Input
-                name="quota"
+                name='quota'
                 placeholder={'请输入新的剩余额度'}
-                onChange={value => handleInputChange('quota', value)}
+                onChange={(value) => handleInputChange('quota', value)}
                 value={quota}
                 type={'number'}
-                autoComplete="new-password"
+                autoComplete='new-password'
               />
             </>
-          }
+          )}
           <Divider style={{ marginTop: 20 }}>以下信息不可修改</Divider>
           <div style={{ marginTop: 20 }}>
             <Typography.Text>已绑定的 GitHub 账户</Typography.Text>
           </div>
           <Input
-            name="github_id"
+            name='github_id'
             value={github_id}
-            autoComplete="new-password"
-            placeholder="此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改"
+            autoComplete='new-password'
+            placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
             readonly
           />
           <div style={{ marginTop: 20 }}>
             <Typography.Text>已绑定的微信账户</Typography.Text>
           </div>
           <Input
-            name="wechat_id"
+            name='wechat_id'
             value={wechat_id}
-            autoComplete="new-password"
-            placeholder="此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改"
+            autoComplete='new-password'
+            placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
             readonly
           />
           <Input
-            name="telegram_id"
+            name='telegram_id'
             value={telegram_id}
-            autoComplete="new-password"
-            placeholder="此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改"
+            autoComplete='new-password'
+            placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
             readonly
           />
           <div style={{ marginTop: 20 }}>
             <Typography.Text>已绑定的邮箱账户</Typography.Text>
           </div>
           <Input
-            name="email"
+            name='email'
             value={email}
-            autoComplete="new-password"
-            placeholder="此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改"
+            autoComplete='new-password'
+            placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
             readonly
           />
         </Spin>

+ 7 - 7
web/src/pages/User/index.js

@@ -1,16 +1,16 @@
 import React from 'react';
 import UsersTable from '../../components/UsersTable';
-import {Layout} from "@douyinfe/semi-ui";
+import { Layout } from '@douyinfe/semi-ui';
 
 const User = () => (
   <>
     <Layout>
-        <Layout.Header>
-            <h3>管理用户</h3>
-        </Layout.Header>
-        <Layout.Content>
-            <UsersTable/>
-        </Layout.Content>
+      <Layout.Header>
+        <h3>管理用户</h3>
+      </Layout.Header>
+      <Layout.Content>
+        <UsersTable />
+      </Layout.Content>
     </Layout>
   </>
 );