Browse Source

chore: reformat code

CaIon 2 năm trước cách đây
mục cha
commit
d34b55c154

+ 8 - 1
common/model-ratio.go

@@ -13,7 +13,7 @@ import (
 // TODO: when a new api is enabled, check the pricing here
 // 1 === $0.002 / 1K tokens
 // 1 === ¥0.014 / 1k tokens
-var ModelRatio = map[string]float64{
+var DefaultModelRatio = map[string]float64{
 	//"midjourney":                50,
 	"gpt-4-gizmo-*":             15,
 	"gpt-4":                     15,
@@ -115,6 +115,7 @@ var DefaultModelPrice = map[string]float64{
 }
 
 var ModelPrice = map[string]float64{}
+var ModelRatio = map[string]float64{}
 
 func ModelPrice2JSONString() string {
 	if len(ModelPrice) == 0 {
@@ -150,6 +151,9 @@ func GetModelPrice(name string, printErr bool) float64 {
 }
 
 func ModelRatio2JSONString() string {
+	if len(ModelRatio) == 0 {
+		ModelRatio = DefaultModelRatio
+	}
 	jsonBytes, err := json.Marshal(ModelRatio)
 	if err != nil {
 		SysError("error marshalling model ratio: " + err.Error())
@@ -163,6 +167,9 @@ func UpdateModelRatioByJSONString(jsonStr string) error {
 }
 
 func GetModelRatio(name string) float64 {
+	if len(ModelRatio) == 0 {
+		ModelRatio = DefaultModelRatio
+	}
 	if strings.HasPrefix(name, "gpt-4-gizmo") {
 		name = "gpt-4-gizmo-*"
 	}

+ 1 - 1
web/package.json

@@ -49,7 +49,7 @@
     ]
   },
   "devDependencies": {
-    "prettier": "^2.7.1",
+    "prettier": "2.8.8",
     "typescript": "4.4.2"
   },
   "prettier": {

+ 185 - 185
web/src/App.js

@@ -8,12 +8,11 @@ import LoginForm from './components/LoginForm';
 import NotFound from './pages/NotFound';
 import Setting from './pages/Setting';
 import EditUser from './pages/User/EditUser';
-import { API, getLogo, getSystemName, showError, showNotice } from './helpers';
+import { getLogo, getSystemName } from './helpers';
 import PasswordResetForm from './components/PasswordResetForm';
 import GitHubOAuth from './components/GitHubOAuth';
 import PasswordResetConfirm from './components/PasswordResetConfirm';
 import { UserContext } from './context/User';
-import { StatusContext } from './context/Status';
 import Channel from './pages/Channel';
 import Token from './pages/Token';
 import EditChannel from './pages/Channel/EditChannel';
@@ -21,12 +20,13 @@ import Redemption from './pages/Redemption';
 import TopUp from './pages/TopUp';
 import Log from './pages/Log';
 import Chat from './pages/Chat';
-import {Layout} from "@douyinfe/semi-ui";
-import Midjourney from "./pages/Midjourney";
-import Detail from "./pages/Detail";
+import { Layout } from '@douyinfe/semi-ui';
+import Midjourney from './pages/Midjourney';
+import Detail from './pages/Detail';
 
 const Home = lazy(() => import('./pages/Home'));
 const About = lazy(() => import('./pages/About'));
+
 function App() {
   const [userState, userDispatch] = useContext(UserContext);
   // const [statusState, statusDispatch] = useContext(StatusContext);
@@ -47,7 +47,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;
       }
@@ -56,185 +56,185 @@ function App() {
 
   return (
     <Layout>
-        <Layout.Content>
-            <Routes>
-                <Route
-                    path='/'
-                    element={
-                        <Suspense fallback={<Loading></Loading>}>
-                            <Home />
-                        </Suspense>
-                    }
-                />
-                <Route
-                    path='/channel'
-                    element={
-                        <PrivateRoute>
-                            <Channel />
-                        </PrivateRoute>
-                    }
-                />
-                <Route
-                    path='/channel/edit/:id'
-                    element={
-                        <Suspense fallback={<Loading></Loading>}>
-                            <EditChannel />
-                        </Suspense>
-                    }
-                />
-                <Route
-                    path='/channel/add'
-                    element={
-                        <Suspense fallback={<Loading></Loading>}>
-                            <EditChannel />
-                        </Suspense>
-                    }
-                />
-                <Route
-                    path='/token'
-                    element={
-                        <PrivateRoute>
-                            <Token />
-                        </PrivateRoute>
-                    }
-                />
-                <Route
-                    path='/redemption'
-                    element={
-                        <PrivateRoute>
-                            <Redemption />
-                        </PrivateRoute>
-                    }
-                />
-                <Route
-                    path='/user'
-                    element={
-                        <PrivateRoute>
-                            <User />
-                        </PrivateRoute>
-                    }
-                />
-                <Route
-                    path='/user/edit/:id'
-                    element={
-                        <Suspense fallback={<Loading></Loading>}>
-                            <EditUser />
-                        </Suspense>
-                    }
-                />
-                <Route
-                    path='/user/edit'
-                    element={
-                        <Suspense fallback={<Loading></Loading>}>
-                            <EditUser />
-                        </Suspense>
-                    }
-                />
-                <Route
-                    path='/user/reset'
-                    element={
-                        <Suspense fallback={<Loading></Loading>}>
-                            <PasswordResetConfirm />
-                        </Suspense>
-                    }
-                />
-                <Route
-                    path='/login'
-                    element={
-                        <Suspense fallback={<Loading></Loading>}>
-                            <LoginForm />
-                        </Suspense>
-                    }
-                />
-                <Route
-                    path='/register'
-                    element={
-                        <Suspense fallback={<Loading></Loading>}>
-                            <RegisterForm />
-                        </Suspense>
-                    }
-                />
-                <Route
-                    path='/reset'
-                    element={
-                        <Suspense fallback={<Loading></Loading>}>
-                            <PasswordResetForm />
-                        </Suspense>
-                    }
-                />
-                <Route
-                    path='/oauth/github'
-                    element={
-                        <Suspense fallback={<Loading></Loading>}>
-                            <GitHubOAuth />
-                        </Suspense>
-                    }
-                />
-                <Route
-                    path='/setting'
-                    element={
-                        <PrivateRoute>
-                            <Suspense fallback={<Loading></Loading>}>
-                                <Setting />
-                            </Suspense>
-                        </PrivateRoute>
-                    }
-                />
-                <Route
-                    path='/topup'
-                    element={
-                        <PrivateRoute>
-                            <Suspense fallback={<Loading></Loading>}>
-                                <TopUp />
-                            </Suspense>
-                        </PrivateRoute>
-                    }
-                />
-                <Route
-                    path='/log'
-                    element={
-                        <PrivateRoute>
-                            <Log />
-                        </PrivateRoute>
-                    }
-                />
-                <Route
-                    path='/detail'
-                    element={
-                        <PrivateRoute>
-                            <Detail />
-                        </PrivateRoute>
-                    }
-                />
-                <Route
-                    path='/midjourney'
-                    element={
-                        <PrivateRoute>
-                            <Midjourney />
-                        </PrivateRoute>
-                    }
-                />
-                <Route
-                    path='/about'
-                    element={
-                        <Suspense fallback={<Loading></Loading>}>
-                            <About />
-                        </Suspense>
-                    }
-                />
-                <Route
-                    path='/chat'
-                    element={
-                        <Suspense fallback={<Loading></Loading>}>
-                            <Chat />
-                        </Suspense>
-                    }
-                />
-                <Route path='*' element={
-                    <NotFound />
-                } />
-            </Routes>
-        </Layout.Content>
+      <Layout.Content>
+        <Routes>
+          <Route
+            path="/"
+            element={
+              <Suspense fallback={<Loading></Loading>}>
+                <Home />
+              </Suspense>
+            }
+          />
+          <Route
+            path="/channel"
+            element={
+              <PrivateRoute>
+                <Channel />
+              </PrivateRoute>
+            }
+          />
+          <Route
+            path="/channel/edit/:id"
+            element={
+              <Suspense fallback={<Loading></Loading>}>
+                <EditChannel />
+              </Suspense>
+            }
+          />
+          <Route
+            path="/channel/add"
+            element={
+              <Suspense fallback={<Loading></Loading>}>
+                <EditChannel />
+              </Suspense>
+            }
+          />
+          <Route
+            path="/token"
+            element={
+              <PrivateRoute>
+                <Token />
+              </PrivateRoute>
+            }
+          />
+          <Route
+            path="/redemption"
+            element={
+              <PrivateRoute>
+                <Redemption />
+              </PrivateRoute>
+            }
+          />
+          <Route
+            path="/user"
+            element={
+              <PrivateRoute>
+                <User />
+              </PrivateRoute>
+            }
+          />
+          <Route
+            path="/user/edit/:id"
+            element={
+              <Suspense fallback={<Loading></Loading>}>
+                <EditUser />
+              </Suspense>
+            }
+          />
+          <Route
+            path="/user/edit"
+            element={
+              <Suspense fallback={<Loading></Loading>}>
+                <EditUser />
+              </Suspense>
+            }
+          />
+          <Route
+            path="/user/reset"
+            element={
+              <Suspense fallback={<Loading></Loading>}>
+                <PasswordResetConfirm />
+              </Suspense>
+            }
+          />
+          <Route
+            path="/login"
+            element={
+              <Suspense fallback={<Loading></Loading>}>
+                <LoginForm />
+              </Suspense>
+            }
+          />
+          <Route
+            path="/register"
+            element={
+              <Suspense fallback={<Loading></Loading>}>
+                <RegisterForm />
+              </Suspense>
+            }
+          />
+          <Route
+            path="/reset"
+            element={
+              <Suspense fallback={<Loading></Loading>}>
+                <PasswordResetForm />
+              </Suspense>
+            }
+          />
+          <Route
+            path="/oauth/github"
+            element={
+              <Suspense fallback={<Loading></Loading>}>
+                <GitHubOAuth />
+              </Suspense>
+            }
+          />
+          <Route
+            path="/setting"
+            element={
+              <PrivateRoute>
+                <Suspense fallback={<Loading></Loading>}>
+                  <Setting />
+                </Suspense>
+              </PrivateRoute>
+            }
+          />
+          <Route
+            path="/topup"
+            element={
+              <PrivateRoute>
+                <Suspense fallback={<Loading></Loading>}>
+                  <TopUp />
+                </Suspense>
+              </PrivateRoute>
+            }
+          />
+          <Route
+            path="/log"
+            element={
+              <PrivateRoute>
+                <Log />
+              </PrivateRoute>
+            }
+          />
+          <Route
+            path="/detail"
+            element={
+              <PrivateRoute>
+                <Detail />
+              </PrivateRoute>
+            }
+          />
+          <Route
+            path="/midjourney"
+            element={
+              <PrivateRoute>
+                <Midjourney />
+              </PrivateRoute>
+            }
+          />
+          <Route
+            path="/about"
+            element={
+              <Suspense fallback={<Loading></Loading>}>
+                <About />
+              </Suspense>
+            }
+          />
+          <Route
+            path="/chat"
+            element={
+              <Suspense fallback={<Loading></Loading>}>
+                <Chat />
+              </Suspense>
+            }
+          />
+          <Route path="*" element={
+            <NotFound />
+          } />
+        </Routes>
+      </Layout.Content>
     </Layout>
   );
 }

+ 692 - 687
web/src/components/ChannelsTable.js

@@ -1,723 +1,728 @@
-import React, {useEffect, useState} from 'react';
-import {
-    API,
-    isMobile,
-    shouldShowPrompt,
-    showError,
-    showInfo,
-    showSuccess,
-    timestamp2string
-} from '../helpers';
-
-import {CHANNEL_OPTIONS, ITEMS_PER_PAGE} from '../constants';
-import {renderGroup, renderNumber, renderNumberWithPoint, renderQuota, renderQuotaWithPrompt} from '../helpers/render';
+import React, { useEffect, useState } from 'react';
+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 {
-    Avatar,
-    Tag,
-    Table,
     Button,
-    Popover,
+    Dropdown,
     Form,
-    Modal,
+    InputNumber,
     Popconfirm,
     Space,
-    Tooltip,
+    SplitButtonGroup,
     Switch,
-    Typography, InputNumber, Dropdown, SplitButtonGroup
-} from "@douyinfe/semi-ui";
-import EditChannel from "../pages/Channel/EditChannel";
-import {IconTreeTriangleDown} from "@douyinfe/semi-icons";
+    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;
 
 function renderType(type) {
-    if (!type2label) {
-        type2label = new Map;
-        for (let i = 0; i < CHANNEL_OPTIONS.length; i++) {
-            type2label[CHANNEL_OPTIONS[i].value] = CHANNEL_OPTIONS[i];
-        }
-        type2label[0] = {value: 0, text: '未知类型', color: 'grey'};
+  if (!type2label) {
+    type2label = new Map();
+    for (let i = 0; i < CHANNEL_OPTIONS.length; i++) {
+      type2label[CHANNEL_OPTIONS[i].value] = CHANNEL_OPTIONS[i];
     }
-    return <Tag size='large' color={type2label[type]?.color}>{type2label[type]?.text}</Tag>;
+    type2label[0] = { value: 0, text: '未知类型', color: 'grey' };
+  }
+  return <Tag size="large" color={type2label[type]?.color}>{type2label[type]?.text}</Tag>;
 }
 
 const ChannelsTable = () => {
-    const columns = [
-        // {
-        //     title: '',
-        //     dataIndex: 'checkbox',
-        //     className: 'checkbox',
-        // },
-        {
-            title: 'ID',
-            dataIndex: 'id',
-        },
-        {
-            title: '名称',
-            dataIndex: 'name',
-        },
-        {
-            title: '分组',
-            dataIndex: 'group',
-            render: (text, record, index) => {
-                return (
-                    <div>
-                        <Space spacing={2}>
-                            {
-                                text.split(',').map((item, index) => {
-                                    return (renderGroup(item))
-                                })
-                            }
-                        </Space>
-                    </div>
-                );
-            },
-        },
-        {
-            title: '类型',
-            dataIndex: 'type',
-            render: (text, record, index) => {
-                return (
-                    <div>
-                        {renderType(text)}
-                    </div>
-                );
-            },
-        },
-        {
-            title: '状态',
-            dataIndex: 'status',
-            render: (text, record, index) => {
-                return (
-                    <div>
-                        {renderStatus(text)}
-                    </div>
-                );
-            },
-        },
-        {
-            title: '响应时间',
-            dataIndex: 'response_time',
-            render: (text, record, index) => {
-                return (
-                    <div>
-                        {renderResponseTime(text)}
-                    </div>
-                );
-            },
-        },
-        {
-            title: '已用/剩余',
-            dataIndex: 'expired_time',
-            render: (text, record, index) => {
-                return (
-                    <div>
-                        <Space spacing={1}>
-                            <Tooltip content={'已用额度'}>
-                                <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>
-                            </Tooltip>
-                        </Space>
-                    </div>
-                );
-            },
-        },
-        {
-            title: '优先级',
-            dataIndex: 'priority',
-            render: (text, record, index) => {
-                return (
-                    <div>
-                        <InputNumber
-                            style={{width: 70}}
-                            name='priority'
-                            onBlur={e => {
-                                manageChannel(record.id, 'priority', record, e.target.value);
-                            }}
-                            keepFocus={true}
-                            innerButtons
-                            defaultValue={record.priority}
-                            min={-999}
-                        />
-                    </div>
-                );
-            },
-        },
-        {
-            title: '权重',
-            dataIndex: 'weight',
-            render: (text, record, index) => {
-                return (
-                    <div>
-                        <InputNumber
-                            style={{width: 70}}
-                            name='weight'
-                            onBlur={e => {
-                                manageChannel(record.id, 'weight', record, e.target.value);
-                            }}
-                            keepFocus={true}
-                            innerButtons
-                            defaultValue={record.weight}
-                            min={0}
-                        />
-                    </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}
-                        >
-                            <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="此修改将不可逆"
-                        okType={'danger'}
-                        position={'left'}
-                        onConfirm={() => {
-                            manageChannel(record.id, 'delete', record).then(
-                                () => {
-                                    removeRecord(record.id);
-                                }
-                            )
-                        }}
-                    >
-                        <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={
-                        () => {
-                            setEditingChannel(record);
-                            setShowEdit(true);
-                        }
-                    }>编辑</Button>
-                </div>
-            ),
-        },
-    ];
-
-    const [channels, setChannels] = useState([]);
-    const [loading, setLoading] = useState(true);
-    const [activePage, setActivePage] = useState(1);
-    const [idSort, setIdSort] = useState(false);
-    const [searchKeyword, setSearchKeyword] = useState('');
-    const [searchGroup, setSearchGroup] = useState('');
-    const [searchModel, setSearchModel] = useState('');
-    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 [channelCount, setChannelCount] = useState(pageSize);
-    const [groupOptions, setGroupOptions] = useState([]);
-    const [showEdit, setShowEdit] = useState(false);
-    const [enableBatchDelete, setEnableBatchDelete] = useState(false);
-    const [editingChannel, setEditingChannel] = useState({
-        id: undefined,
-    });
-    const [selectedChannels, setSelectedChannels] = useState([]);
-
-    const removeRecord = id => {
-        let newDataSource = [...channels];
-        if (id != null) {
-            let idx = newDataSource.findIndex(data => data.id === id);
-
-            if (idx > -1) {
-                newDataSource.splice(idx, 1);
-                setChannels(newDataSource);
-            }
-        }
-    };
-
-    const setChannelFormat = (channels) => {
-        for (let i = 0; i < channels.length; i++) {
-            channels[i].key = '' + channels[i].id;
-            let test_models = []
-            channels[i].models.split(',').forEach((item, index) => {
-                test_models.push({
-                    node: 'item',
-                    name: item,
-                    onClick: () => {
-                        testChannel(channels[i], item)
-                    }
+  const columns = [
+    // {
+    //     title: '',
+    //     dataIndex: 'checkbox',
+    //     className: 'checkbox',
+    // },
+    {
+      title: 'ID',
+      dataIndex: 'id'
+    },
+    {
+      title: '名称',
+      dataIndex: 'name'
+    },
+    {
+      title: '分组',
+      dataIndex: 'group',
+      render: (text, record, index) => {
+        return (
+          <div>
+            <Space spacing={2}>
+              {
+                text.split(',').map((item, index) => {
+                  return (renderGroup(item));
                 })
-            })
-            channels[i].test_models = test_models
-        }
-        // data.key = '' + data.id
-        setChannels(channels);
-        if (channels.length >= pageSize) {
-            setChannelCount(channels.length + pageSize);
-        } else {
-            setChannelCount(channels.length);
-        }
-    }
-
-    const loadChannels = async (startIdx, pageSize, idSort) => {
-        setLoading(true);
-        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) {
-                setChannelFormat(data);
-            } else {
-                let newChannels = [...channels];
-                newChannels.splice(startIdx * pageSize, data.length, ...data);
-                setChannelFormat(newChannels);
-            }
-        } else {
-            showError(message);
-        }
-        setLoading(false);
-    };
-
-    const refresh = async () => {
-        await loadChannels(activePage - 1, pageSize, idSort);
-    };
-
-    useEffect(() => {
-        // console.log('default effect')
-        const localIdSort = localStorage.getItem('id-sort') === 'true';
-        const localPageSize = parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
-        setIdSort(localIdSort)
-        setPageSize(localPageSize)
-        loadChannels(0, localPageSize, localIdSort)
-            .then()
-            .catch((reason) => {
-                showError(reason);
-            });
-        fetchGroups().then();
-    }, []);
-
-    const manageChannel = async (id, action, record, value) => {
-        let data = {id};
-        let res;
-        switch (action) {
-            case 'delete':
-                res = await API.delete(`/api/channel/${id}/`);
-                break;
-            case 'enable':
-                data.status = 1;
-                res = await API.put('/api/channel/', data);
-                break;
-            case 'disable':
-                data.status = 2;
-                res = await API.put('/api/channel/', data);
-                break;
-            case 'priority':
-                if (value === '') {
-                    return;
+              }
+            </Space>
+          </div>
+        );
+      }
+    },
+    {
+      title: '类型',
+      dataIndex: 'type',
+      render: (text, record, index) => {
+        return (
+          <div>
+            {renderType(text)}
+          </div>
+        );
+      }
+    },
+    {
+      title: '状态',
+      dataIndex: 'status',
+      render: (text, record, index) => {
+        return (
+          <div>
+            {renderStatus(text)}
+          </div>
+        );
+      }
+    },
+    {
+      title: '响应时间',
+      dataIndex: 'response_time',
+      render: (text, record, index) => {
+        return (
+          <div>
+            {renderResponseTime(text)}
+          </div>
+        );
+      }
+    },
+    {
+      title: '已用/剩余',
+      dataIndex: 'expired_time',
+      render: (text, record, index) => {
+        return (
+          <div>
+            <Space spacing={1}>
+              <Tooltip content={'已用额度'}>
+                <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>
+              </Tooltip>
+            </Space>
+          </div>
+        );
+      }
+    },
+    {
+      title: '优先级',
+      dataIndex: 'priority',
+      render: (text, record, index) => {
+        return (
+          <div>
+            <InputNumber
+              style={{ width: 70 }}
+              name="priority"
+              onBlur={e => {
+                manageChannel(record.id, 'priority', record, e.target.value);
+              }}
+              keepFocus={true}
+              innerButtons
+              defaultValue={record.priority}
+              min={-999}
+            />
+          </div>
+        );
+      }
+    },
+    {
+      title: '权重',
+      dataIndex: 'weight',
+      render: (text, record, index) => {
+        return (
+          <div>
+            <InputNumber
+              style={{ width: 70 }}
+              name="weight"
+              onBlur={e => {
+                manageChannel(record.id, 'weight', record, e.target.value);
+              }}
+              keepFocus={true}
+              innerButtons
+              defaultValue={record.weight}
+              min={0}
+            />
+          </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}
+            >
+              <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="此修改将不可逆"
+            okType={'danger'}
+            position={'left'}
+            onConfirm={() => {
+              manageChannel(record.id, 'delete', record).then(
+                () => {
+                  removeRecord(record.id);
                 }
-                data.priority = parseInt(value);
-                res = await API.put('/api/channel/', data);
-                break;
-            case 'weight':
-                if (value === '') {
-                    return;
+              );
+            }}
+          >
+            <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
+                  );
                 }
-                data.weight = parseInt(value);
-                if (data.weight < 0) {
-                    data.weight = 0;
+              }>禁用</Button> :
+              <Button theme="light" type="secondary" style={{ marginRight: 1 }} onClick={
+                async () => {
+                  manageChannel(
+                    record.id,
+                    'enable',
+                    record
+                  );
                 }
-                res = await API.put('/api/channel/', data);
-                break;
-        }
-        const {success, message} = res.data;
-        if (success) {
-            showSuccess('操作成功完成!');
-            let channel = res.data.data;
-            let newChannels = [...channels];
-            if (action === 'delete') {
-
-            } else {
-                record.status = channel.status;
+              }>启用</Button>
+          }
+          <Button theme="light" type="tertiary" style={{ marginRight: 1 }} onClick={
+            () => {
+              setEditingChannel(record);
+              setShowEdit(true);
             }
-            setChannels(newChannels);
-        } else {
-            showError(message);
-        }
-    };
-
-    const renderStatus = (status) => {
-        switch (status) {
-            case 1:
-                return <Tag size='large' color='green'>已启用</Tag>;
-            case 2:
-                return (
-                    <Tag size='large' color='yellow'>
-                        已禁用
-                    </Tag>
-                );
-            case 3:
-                return (
-                    <Tag size='large' color='yellow'>
-                        自动禁用
-                    </Tag>
-                );
-            default:
-                return (
-                    <Tag size='large' color='grey'>
-                        未知状态
-                    </Tag>
-                );
-        }
-    };
-
-    const renderResponseTime = (responseTime) => {
-        let time = responseTime / 1000;
-        time = time.toFixed(2) + ' 秒';
-        if (responseTime === 0) {
-            return <Tag size='large' color='grey'>未测试</Tag>;
-        } else if (responseTime <= 1000) {
-            return <Tag size='large' color='green'>{time}</Tag>;
-        } else if (responseTime <= 3000) {
-            return <Tag size='large' color='lime'>{time}</Tag>;
-        } else if (responseTime <= 5000) {
-            return <Tag size='large' color='yellow'>{time}</Tag>;
-        } else {
-            return <Tag size='large' color='red'>{time}</Tag>;
-        }
-    };
-
-    const searchChannels = async (searchKeyword, searchGroup, searchModel) => {
-        if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
-            // if keyword is blank, load files instead.
-            await loadChannels(0, pageSize, idSort);
-            setActivePage(1);
-            return;
-        }
-        setSearching(true);
-        const res = await API.get(`/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}`);
-        const {success, message, data} = res.data;
-        if (success) {
-            setChannels(data);
-            setActivePage(1);
-        } else {
-            showError(message);
-        }
-        setSearching(false);
-    };
-
-    const testChannel = async (record, model) => {
-        const res = await API.get(`/api/channel/test/${record.id}?model=${model}`);
-        const {success, message, time} = res.data;
-        if (success) {
-            record.response_time = time * 1000;
-            record.test_time = Date.now() / 1000;
-            showInfo(`通道 ${record.name} 测试成功,耗时 ${time.toFixed(2)} 秒。`);
-        } else {
-            showError(message);
-        }
-    };
-
-    const testAllChannels = async () => {
-        const res = await API.get(`/api/channel/test`);
-        const {success, message} = res.data;
-        if (success) {
-            showInfo('已成功开始测试所有通道,请刷新页面查看结果。');
-        } else {
-            showError(message);
-        }
-    };
-
-    const deleteAllDisabledChannels = async () => {
-        const res = await API.delete(`/api/channel/disabled`);
-        const {success, message, data} = res.data;
-        if (success) {
-            showSuccess(`已删除所有禁用渠道,共计 ${data} 个`);
-            await refresh();
-        } else {
-            showError(message);
-        }
-    };
-
-    const updateChannelBalance = async (record) => {
-        const res = await API.get(`/api/channel/update_balance/${record.id}/`);
-        const {success, message, balance} = res.data;
-        if (success) {
-            record.balance = balance;
-            record.balance_updated_time = Date.now() / 1000;
-            showInfo(`通道 ${record.name} 余额更新成功!`);
-        } else {
-            showError(message);
-        }
-    };
-
-    const updateAllChannelsBalance = async () => {
-        setUpdatingBalance(true);
-        const res = await API.get(`/api/channel/update_balance`);
-        const {success, message} = res.data;
-        if (success) {
-            showInfo('已更新完毕所有已启用通道余额!');
-        } else {
-            showError(message);
+          }>编辑</Button>
+        </div>
+      )
+    }
+  ];
+
+  const [channels, setChannels] = useState([]);
+  const [loading, setLoading] = useState(true);
+  const [activePage, setActivePage] = useState(1);
+  const [idSort, setIdSort] = useState(false);
+  const [searchKeyword, setSearchKeyword] = useState('');
+  const [searchGroup, setSearchGroup] = useState('');
+  const [searchModel, setSearchModel] = useState('');
+  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 [channelCount, setChannelCount] = useState(pageSize);
+  const [groupOptions, setGroupOptions] = useState([]);
+  const [showEdit, setShowEdit] = useState(false);
+  const [enableBatchDelete, setEnableBatchDelete] = useState(false);
+  const [editingChannel, setEditingChannel] = useState({
+    id: undefined
+  });
+  const [selectedChannels, setSelectedChannels] = useState([]);
+
+  const removeRecord = id => {
+    let newDataSource = [...channels];
+    if (id != null) {
+      let idx = newDataSource.findIndex(data => data.id === id);
+
+      if (idx > -1) {
+        newDataSource.splice(idx, 1);
+        setChannels(newDataSource);
+      }
+    }
+  };
+
+  const setChannelFormat = (channels) => {
+    for (let i = 0; i < channels.length; i++) {
+      channels[i].key = '' + channels[i].id;
+      let test_models = [];
+      channels[i].models.split(',').forEach((item, index) => {
+        test_models.push({
+          node: 'item',
+          name: item,
+          onClick: () => {
+            testChannel(channels[i], item);
+          }
+        });
+      });
+      channels[i].test_models = test_models;
+    }
+    // data.key = '' + data.id
+    setChannels(channels);
+    if (channels.length >= pageSize) {
+      setChannelCount(channels.length + pageSize);
+    } else {
+      setChannelCount(channels.length);
+    }
+  };
+
+  const loadChannels = async (startIdx, pageSize, idSort) => {
+    setLoading(true);
+    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) {
+        setChannelFormat(data);
+      } else {
+        let newChannels = [...channels];
+        newChannels.splice(startIdx * pageSize, data.length, ...data);
+        setChannelFormat(newChannels);
+      }
+    } else {
+      showError(message);
+    }
+    setLoading(false);
+  };
+
+  const refresh = async () => {
+    await loadChannels(activePage - 1, pageSize, idSort);
+  };
+
+  useEffect(() => {
+    // console.log('default effect')
+    const localIdSort = localStorage.getItem('id-sort') === 'true';
+    const localPageSize = parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
+    setIdSort(localIdSort);
+    setPageSize(localPageSize);
+    loadChannels(0, localPageSize, localIdSort)
+      .then()
+      .catch((reason) => {
+        showError(reason);
+      });
+    fetchGroups().then();
+  }, []);
+
+  const manageChannel = async (id, action, record, value) => {
+    let data = { id };
+    let res;
+    switch (action) {
+      case 'delete':
+        res = await API.delete(`/api/channel/${id}/`);
+        break;
+      case 'enable':
+        data.status = 1;
+        res = await API.put('/api/channel/', data);
+        break;
+      case 'disable':
+        data.status = 2;
+        res = await API.put('/api/channel/', data);
+        break;
+      case 'priority':
+        if (value === '') {
+          return;
         }
-        setUpdatingBalance(false);
-    };
-
-    const batchDeleteChannels = async () => {
-        if (selectedChannels.length === 0) {
-            showError('请先选择要删除的通道!');
-            return;
+        data.priority = parseInt(value);
+        res = await API.put('/api/channel/', data);
+        break;
+      case 'weight':
+        if (value === '') {
+          return;
         }
-        setLoading(true);
-        let ids = [];
-        selectedChannels.forEach((channel) => {
-            ids.push(channel.id);
-        });
-        const res = await API.post(`/api/channel/batch`, {ids: ids});
-        const {success, message, data} = res.data;
-        if (success) {
-            showSuccess(`已删除 ${data} 个通道!`);
-            await refresh();
-        } else {
-            showError(message);
+        data.weight = parseInt(value);
+        if (data.weight < 0) {
+          data.weight = 0;
         }
-        setLoading(false);
+        res = await API.put('/api/channel/', data);
+        break;
     }
-
-    const fixChannelsAbilities = async () => {
-        const res = await API.post(`/api/channel/fix`);
-        const {success, message, data} = res.data;
-        if (success) {
-            showSuccess(`已修复 ${data} 个通道!`);
-            await refresh();
-        } else {
-            showError(message);
-        }
+    const { success, message } = res.data;
+    if (success) {
+      showSuccess('操作成功完成!');
+      let channel = res.data.data;
+      let newChannels = [...channels];
+      if (action === 'delete') {
+
+      } else {
+        record.status = channel.status;
+      }
+      setChannels(newChannels);
+    } else {
+      showError(message);
+    }
+  };
+
+  const renderStatus = (status) => {
+    switch (status) {
+      case 1:
+        return <Tag size="large" color="green">已启用</Tag>;
+      case 2:
+        return (
+          <Tag size="large" color="yellow">
+            已禁用
+          </Tag>
+        );
+      case 3:
+        return (
+          <Tag size="large" color="yellow">
+            自动禁用
+          </Tag>
+        );
+      default:
+        return (
+          <Tag size="large" color="grey">
+            未知状态
+          </Tag>
+        );
+    }
+  };
+
+  const renderResponseTime = (responseTime) => {
+    let time = responseTime / 1000;
+    time = time.toFixed(2) + ' 秒';
+    if (responseTime === 0) {
+      return <Tag size="large" color="grey">未测试</Tag>;
+    } else if (responseTime <= 1000) {
+      return <Tag size="large" color="green">{time}</Tag>;
+    } else if (responseTime <= 3000) {
+      return <Tag size="large" color="lime">{time}</Tag>;
+    } else if (responseTime <= 5000) {
+      return <Tag size="large" color="yellow">{time}</Tag>;
+    } else {
+      return <Tag size="large" color="red">{time}</Tag>;
+    }
+  };
+
+  const searchChannels = async (searchKeyword, searchGroup, searchModel) => {
+    if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
+      // if keyword is blank, load files instead.
+      await loadChannels(0, pageSize, idSort);
+      setActivePage(1);
+      return;
+    }
+    setSearching(true);
+    const res = await API.get(`/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}`);
+    const { success, message, data } = res.data;
+    if (success) {
+      setChannels(data);
+      setActivePage(1);
+    } else {
+      showError(message);
     }
+    setSearching(false);
+  };
+
+  const testChannel = async (record, model) => {
+    const res = await API.get(`/api/channel/test/${record.id}?model=${model}`);
+    const { success, message, time } = res.data;
+    if (success) {
+      record.response_time = time * 1000;
+      record.test_time = Date.now() / 1000;
+      showInfo(`通道 ${record.name} 测试成功,耗时 ${time.toFixed(2)} 秒。`);
+    } else {
+      showError(message);
+    }
+  };
+
+  const testAllChannels = async () => {
+    const res = await API.get(`/api/channel/test`);
+    const { success, message } = res.data;
+    if (success) {
+      showInfo('已成功开始测试所有通道,请刷新页面查看结果。');
+    } else {
+      showError(message);
+    }
+  };
+
+  const deleteAllDisabledChannels = async () => {
+    const res = await API.delete(`/api/channel/disabled`);
+    const { success, message, data } = res.data;
+    if (success) {
+      showSuccess(`已删除所有禁用渠道,共计 ${data} 个`);
+      await refresh();
+    } else {
+      showError(message);
+    }
+  };
+
+  const updateChannelBalance = async (record) => {
+    const res = await API.get(`/api/channel/update_balance/${record.id}/`);
+    const { success, message, balance } = res.data;
+    if (success) {
+      record.balance = balance;
+      record.balance_updated_time = Date.now() / 1000;
+      showInfo(`通道 ${record.name} 余额更新成功!`);
+    } else {
+      showError(message);
+    }
+  };
+
+  const updateAllChannelsBalance = async () => {
+    setUpdatingBalance(true);
+    const res = await API.get(`/api/channel/update_balance`);
+    const { success, message } = res.data;
+    if (success) {
+      showInfo('已更新完毕所有已启用通道余额!');
+    } else {
+      showError(message);
+    }
+    setUpdatingBalance(false);
+  };
 
-    let pageData = channels.slice((activePage - 1) * pageSize, activePage * pageSize);
+  const batchDeleteChannels = async () => {
+    if (selectedChannels.length === 0) {
+      showError('请先选择要删除的通道!');
+      return;
+    }
+    setLoading(true);
+    let ids = [];
+    selectedChannels.forEach((channel) => {
+      ids.push(channel.id);
+    });
+    const res = await API.post(`/api/channel/batch`, { ids: ids });
+    const { success, message, data } = res.data;
+    if (success) {
+      showSuccess(`已删除 ${data} 个通道!`);
+      await refresh();
+    } else {
+      showError(message);
+    }
+    setLoading(false);
+  };
+
+  const fixChannelsAbilities = async () => {
+    const res = await API.post(`/api/channel/fix`);
+    const { success, message, data } = res.data;
+    if (success) {
+      showSuccess(`已修复 ${data} 个通道!`);
+      await refresh();
+    } else {
+      showError(message);
+    }
+  };
 
-    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 => {
-            });
-        }
-    };
-
-    const handlePageSizeChange = async(size) => {
-        localStorage.setItem('page-size', size + '')
-        setPageSize(size)
-        setActivePage(1)
-        loadChannels(0, size, idSort)
-            .then()
-            .catch((reason) => {
-                showError(reason);
-            })
-    };
-
-    const fetchGroups = async () => {
-        try {
-            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,
-            })));
-        } catch (error) {
-            showError(error.message);
-        }
-    };
+  let pageData = channels.slice((activePage - 1) * pageSize, activePage * pageSize);
 
-    const closeEdit = () => {
-        setShowEdit(false);
+  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 => {
+      });
     }
+  };
+
+  const handlePageSizeChange = async (size) => {
+    localStorage.setItem('page-size', size + '');
+    setPageSize(size);
+    setActivePage(1);
+    loadChannels(0, size, idSort)
+      .then()
+      .catch((reason) => {
+        showError(reason);
+      });
+  };
+
+  const fetchGroups = async () => {
+    try {
+      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
+      })));
+    } catch (error) {
+      showError(error.message);
+    }
+  };
+
+  const closeEdit = () => {
+    setShowEdit(false);
+  };
 
-    const handleRow = (record, index) => {
-        if (record.status !== 1) {
-            return {
-                style: {
-                    background: 'var(--semi-color-disabled-border)',
-                },
-            };
-        } else {
-            return {};
+  const handleRow = (record, index) => {
+    if (record.status !== 1) {
+      return {
+        style: {
+          background: 'var(--semi-color-disabled-border)'
         }
-    };
-
-
-    return (
-        <>
-            <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,名称和密钥 ...'
-                            value={searchKeyword}
-                            loading={searching}
-                            onChange={(v)=>{
-                                setSearchKeyword(v.trim())
-                            }}
-                        />
-                        <Form.Input
-                          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>
-                    </Space>
-                </div>
-            </Form>
-            <div style={{marginTop: 10, display: 'flex'}}>
-                <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>
-                    </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={
-                        () => {
-                            setEditingChannel({
-                                id: undefined,
-                            });
-                            setShowEdit(true)
-                        }
-                    }>添加渠道</Button>
-                    <Popconfirm
-                        title="确定?"
-                        okType={'warning'}
-                        onConfirm={testAllChannels}
-                        position={isMobile()?'top':'top'}
-                    >
-                        <Button theme='light' type='warning' style={{marginRight: 8}}>测试所有通道</Button>
-                    </Popconfirm>
-                    <Popconfirm
-                        title="确定?"
-                        okType={'secondary'}
-                        onConfirm={updateAllChannelsBalance}
-                    >
-                        <Button theme='light' type='secondary' style={{marginRight: 8}}>更新所有已启用通道余额</Button>
-                    </Popconfirm>
-                    <Popconfirm
-                        title="确定是否要删除禁用通道?"
-                        content="此修改将不可逆"
-                        okType={'danger'}
-                        onConfirm={deleteAllDisabledChannels}
-                    >
-                        <Button theme='light' type='danger' style={{marginRight: 8}}>删除禁用通道</Button>
-                    </Popconfirm>
-
-                    <Button theme='light' type='primary' style={{marginRight: 8}} onClick={refresh}>刷新</Button>
-                </Space>
-                {/*<div style={{width: '100%', pointerEvents: 'none', position: 'absolute'}}>*/}
-
-                {/*</div>*/}
-            </div>
-            <div style={{marginTop: 20}}>
-                <Space>
-                    <Typography.Text strong>开启批量删除</Typography.Text>
-                    <Switch label='开启批量删除' uncheckedText="关" aria-label="是否开启批量删除" onChange={(v) => {
-                        setEnableBatchDelete(v)
-                    }}></Switch>
-                    <Popconfirm
-                        title="确定是否要删除所选通道?"
-                        content="此修改将不可逆"
-                        okType={'danger'}
-                        onConfirm={batchDeleteChannels}
-                        disabled={!enableBatchDelete}
-                        position={'top'}
-                    >
-                        <Button disabled={!enableBatchDelete} theme='light' type='danger' style={{marginRight: 8}}>删除所选通道</Button>
-                    </Popconfirm>
-                    <Popconfirm
-                        title="确定是否要修复数据库一致性?"
-                        content="进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用"
-                        okType={'warning'}
-                        onConfirm={fixChannelsAbilities}
-                        position={'top'}
-                    >
-                        <Button theme='light' type='secondary' style={{marginRight: 8}}>修复数据库一致性</Button>
-                    </Popconfirm>
-                </Space>
-            </div>
-        </>
-    );
+      };
+    } else {
+      return {};
+    }
+  };
+
+
+  return (
+    <>
+      <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,名称和密钥 ..."
+              value={searchKeyword}
+              loading={searching}
+              onChange={(v) => {
+                setSearchKeyword(v.trim());
+              }}
+            />
+            <Form.Input
+              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>
+          </Space>
+        </div>
+      </Form>
+      <div style={{ marginTop: 10, display: 'flex' }}>
+        <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>
+          </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={
+            () => {
+              setEditingChannel({
+                id: undefined
+              });
+              setShowEdit(true);
+            }
+          }>添加渠道</Button>
+          <Popconfirm
+            title="确定?"
+            okType={'warning'}
+            onConfirm={testAllChannels}
+            position={isMobile() ? 'top' : 'top'}
+          >
+            <Button theme="light" type="warning" style={{ marginRight: 8 }}>测试所有通道</Button>
+          </Popconfirm>
+          <Popconfirm
+            title="确定?"
+            okType={'secondary'}
+            onConfirm={updateAllChannelsBalance}
+          >
+            <Button theme="light" type="secondary" style={{ marginRight: 8 }}>更新所有已启用通道余额</Button>
+          </Popconfirm>
+          <Popconfirm
+            title="确定是否要删除禁用通道?"
+            content="此修改将不可逆"
+            okType={'danger'}
+            onConfirm={deleteAllDisabledChannels}
+          >
+            <Button theme="light" type="danger" style={{ marginRight: 8 }}>删除禁用通道</Button>
+          </Popconfirm>
+
+          <Button theme="light" type="primary" style={{ marginRight: 8 }} onClick={refresh}>刷新</Button>
+        </Space>
+        {/*<div style={{width: '100%', pointerEvents: 'none', position: 'absolute'}}>*/}
+
+        {/*</div>*/}
+      </div>
+      <div style={{ marginTop: 20 }}>
+        <Space>
+          <Typography.Text strong>开启批量删除</Typography.Text>
+          <Switch label="开启批量删除" uncheckedText="关" aria-label="是否开启批量删除" onChange={(v) => {
+            setEnableBatchDelete(v);
+          }}></Switch>
+          <Popconfirm
+            title="确定是否要删除所选通道?"
+            content="此修改将不可逆"
+            okType={'danger'}
+            onConfirm={batchDeleteChannels}
+            disabled={!enableBatchDelete}
+            position={'top'}
+          >
+            <Button disabled={!enableBatchDelete} theme="light" type="danger"
+                    style={{ marginRight: 8 }}>删除所选通道</Button>
+          </Popconfirm>
+          <Popconfirm
+            title="确定是否要修复数据库一致性?"
+            content="进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用"
+            okType={'warning'}
+            onConfirm={fixChannelsAbilities}
+            position={'top'}
+          >
+            <Button theme="light" type="secondary" style={{ marginRight: 8 }}>修复数据库一致性</Button>
+          </Popconfirm>
+        </Space>
+      </div>
+    </>
+  );
 };
 
 export default ChannelsTable;

+ 9 - 9
web/src/components/Footer.js

@@ -1,7 +1,7 @@
 import React, { useEffect, useState } from 'react';
 
 import { getFooterHTML, getSystemName } from '../helpers';
-import {Layout} from "@douyinfe/semi-ui";
+import { Layout } from '@douyinfe/semi-ui';
 
 const Footer = () => {
   const systemName = getSystemName();
@@ -29,30 +29,30 @@ const Footer = () => {
 
   return (
     <Layout>
-      <Layout.Content style={{textAlign: 'center'}}>
+      <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'
+              href="https://github.com/Calcium-Ion/new-api"
+              target="_blank" rel="noreferrer"
             >
               New API {process.env.REACT_APP_VERSION}{' '}
             </a>
             由{' '}
-            <a href='https://github.com/Calcium-Ion' target='_blank'>
+            <a href="https://github.com/Calcium-Ion" target="_blank" rel="noreferrer">
               Calcium-Ion
             </a>{' '}
             开发,基于{' '}
-            <a href='https://github.com/songquanpeng/one-api' target='_blank'>
+            <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>
   );

+ 137 - 141
web/src/components/HeaderBar.js

@@ -1,165 +1,161 @@
-import React, {useContext, useEffect, useRef, useState} from 'react';
-import {Link, useNavigate} from 'react-router-dom';
-import {UserContext} from '../context/User';
+import React, { useContext, useEffect, useState } from 'react';
+import { Link, useNavigate } from 'react-router-dom';
+import { UserContext } from '../context/User';
 
-import {API, getLogo, getSystemName, isAdmin, isMobile, showSuccess} from '../helpers';
+import { API, getLogo, getSystemName, showSuccess } from '../helpers';
 import '../index.css';
 
 import fireworks from 'react-fireworks';
 
-import {
-    IconKey,
-    IconUser,
-    IconHelpCircle
-} from '@douyinfe/semi-icons';
-import {Nav, Avatar, Dropdown, Layout, Switch} from '@douyinfe/semi-ui';
-import {stringToColor} from "../helpers/render";
+import { IconHelpCircle, IconKey, IconUser } from '@douyinfe/semi-icons';
+import { Avatar, Dropdown, Layout, Nav, Switch } from '@douyinfe/semi-ui';
+import { stringToColor } from '../helpers/render';
 
 // HeaderBar Buttons
 let headerButtons = [
-    {
-        text: '关于',
-        itemKey: 'about',
-        to: '/about',
-        icon: <IconHelpCircle/>
-    },
+  {
+    text: '关于',
+    itemKey: 'about',
+    to: '/about',
+    icon: <IconHelpCircle />
+  }
 ];
 
 if (localStorage.getItem('chat_link')) {
-    headerButtons.splice(1, 0, {
-        name: '聊天',
-        to: '/chat',
-        icon: 'comments'
-    });
+  headerButtons.splice(1, 0, {
+    name: '聊天',
+    to: '/chat',
+    icon: 'comments'
+  });
 }
 
 const HeaderBar = () => {
-    const [userState, userDispatch] = useContext(UserContext);
-    let navigate = useNavigate();
+  const [userState, userDispatch] = useContext(UserContext);
+  let navigate = useNavigate();
 
-    const [showSidebar, setShowSidebar] = useState(false);
-    const [dark, setDark] = useState(false);
-    const systemName = getSystemName();
-    const logo = getLogo();
-    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 [showSidebar, setShowSidebar] = useState(false);
+  const [dark, setDark] = useState(false);
+  const systemName = getSystemName();
+  const logo = getLogo();
+  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);
 
-    async function logout() {
-        setShowSidebar(false);
-        await API.get('/api/user/logout');
-        showSuccess('注销成功!');
-        userDispatch({type: 'logout'});
-        localStorage.removeItem('user');
-        navigate('/login');
-    }
+  async function logout() {
+    setShowSidebar(false);
+    await API.get('/api/user/logout');
+    showSuccess('注销成功!');
+    userDispatch({ type: 'logout' });
+    localStorage.removeItem('user');
+    navigate('/login');
+  }
 
-    const handleNewYearClick = () => {
-        fireworks.init("root",{});
-        fireworks.start();
-        setTimeout(() => {
-            fireworks.stop();
-            setTimeout(() => {
-                window.location.reload();
-            }, 10000);
-        }, 3000);
-    };
+  const handleNewYearClick = () => {
+    fireworks.init('root', {});
+    fireworks.start();
+    setTimeout(() => {
+      fireworks.stop();
+      setTimeout(() => {
+        window.location.reload();
+      }, 10000);
+    }, 3000);
+  };
 
-    useEffect(() => {
-        if (themeMode === 'dark') {
-            switchMode(true);
-        }
-        if (isNewYear) {
-            console.log('Happy New Year!');
-        }
-    }, []);
+  useEffect(() => {
+    if (themeMode === 'dark') {
+      switchMode(true);
+    }
+    if (isNewYear) {
+      console.log('Happy New Year!');
+    }
+  }, []);
 
-    const switchMode = (model) => {
-        const body = document.body;
-        if (!model) {
-            body.removeAttribute('theme-mode');
-            localStorage.setItem('theme-mode', 'light');
-        } else {
-            body.setAttribute('theme-mode', 'dark');
-            localStorage.setItem('theme-mode', 'dark');
-        }
-        setDark(model);
-    };
-    return (
-        <>
-            <Layout>
-                <div style={{width: '100%'}}>
-                    <Nav
-                        mode={'horizontal'}
-                        // bodyStyle={{ height: 100 }}
-                        renderWrapper={({itemElement, isSubNav, isInSubNav, props}) => {
-                            const routerMap = {
-                                about: "/about",
-                                login: "/login",
-                                register: "/register",
-                            };
-                            return (
-                                <Link
-                                    style={{textDecoration: "none"}}
-                                    to={routerMap[props.itemKey]}
-                                >
-                                    {itemElement}
-                                </Link>
-                            );
-                        }}
-                        selectedKeys={[]}
-                        // items={headerButtons}
-                        onSelect={key => {
+  const switchMode = (model) => {
+    const body = document.body;
+    if (!model) {
+      body.removeAttribute('theme-mode');
+      localStorage.setItem('theme-mode', 'light');
+    } else {
+      body.setAttribute('theme-mode', 'dark');
+      localStorage.setItem('theme-mode', 'dark');
+    }
+    setDark(model);
+  };
+  return (
+    <>
+      <Layout>
+        <div style={{ width: '100%' }}>
+          <Nav
+            mode={'horizontal'}
+            // bodyStyle={{ height: 100 }}
+            renderWrapper={({ itemElement, isSubNav, isInSubNav, props }) => {
+              const routerMap = {
+                about: '/about',
+                login: '/login',
+                register: '/register'
+              };
+              return (
+                <Link
+                  style={{ textDecoration: 'none' }}
+                  to={routerMap[props.itemKey]}
+                >
+                  {itemElement}
+                </Link>
+              );
+            }}
+            selectedKeys={[]}
+            // items={headerButtons}
+            onSelect={key => {
 
-                        }}
-                        footer={
-                            <>
-                                {isNewYear &&
-                                    // happy new year
-                                    <Dropdown
-                                        position="bottomRight"
-                                        render={
-                                            <Dropdown.Menu>
-                                                <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 ?
-                                    <>
-                                        <Dropdown
-                                            position="bottomRight"
-                                            render={
-                                                <Dropdown.Menu>
-                                                    <Dropdown.Item onClick={logout}>退出</Dropdown.Item>
-                                                </Dropdown.Menu>
-                                            }
-                                        >
-                                            <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 />} />
-                                    </>
-                                }
-                            </>
-                        }
+            }}
+            footer={
+              <>
+                {isNewYear &&
+                  // happy new year
+                  <Dropdown
+                    position="bottomRight"
+                    render={
+                      <Dropdown.Menu>
+                        <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 ?
+                  <>
+                    <Dropdown
+                      position="bottomRight"
+                      render={
+                        <Dropdown.Menu>
+                          <Dropdown.Item onClick={logout}>退出</Dropdown.Item>
+                        </Dropdown.Menu>
+                      }
                     >
-                    </Nav>
-                </div>
-            </Layout>
-        </>
-    );
+                      <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>
+        </div>
+      </Layout>
+    </>
+  );
 };
 
 export default HeaderBar;

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

@@ -1,5 +1,5 @@
 import React from 'react';
-import { Segment, Dimmer, Loader } from 'semantic-ui-react';
+import { Dimmer, Loader, Segment } from 'semantic-ui-react';
 
 const Loading = ({ prompt: name = 'page' }) => {
   return (

+ 227 - 227
web/src/components/LoginForm.js

@@ -1,254 +1,254 @@
 import React, { useContext, useEffect, useState } from 'react';
 import { Link, useNavigate, useSearchParams } from 'react-router-dom';
 import { UserContext } from '../context/User';
-import { API, getLogo, isMobile, showError, showInfo, showSuccess, showWarning } from '../helpers';
+import { API, getLogo, showError, showInfo, showSuccess } from '../helpers';
 import { onGitHubOAuthClicked } from './utils';
-import Turnstile from "react-turnstile";
-import { Layout, Card, Image, Form, Button, Divider, Modal, Icon } 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 Turnstile from 'react-turnstile';
+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';
 
 import { IconGithubLogo } from '@douyinfe/semi-icons';
 import WeChatIcon from './WeChatIcon';
 
 const LoginForm = () => {
-    const [inputs, setInputs] = useState({
-        username: '',
-        password: '',
-        wechat_verification_code: ''
-    });
-    const [searchParams, setSearchParams] = useSearchParams();
-    const [submitted, setSubmitted] = useState(false);
-    const { username, password } = inputs;
-    const [userState, userDispatch] = useContext(UserContext);
-    const [turnstileEnabled, setTurnstileEnabled] = useState(false);
-    const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
-    const [turnstileToken, setTurnstileToken] = useState('');
-    let navigate = useNavigate();
-    const [status, setStatus] = useState({});
-    const logo = getLogo();
+  const [inputs, setInputs] = useState({
+    username: '',
+    password: '',
+    wechat_verification_code: ''
+  });
+  const [searchParams, setSearchParams] = useSearchParams();
+  const [submitted, setSubmitted] = useState(false);
+  const { username, password } = inputs;
+  const [userState, userDispatch] = useContext(UserContext);
+  const [turnstileEnabled, setTurnstileEnabled] = useState(false);
+  const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
+  const [turnstileToken, setTurnstileToken] = useState('');
+  let navigate = useNavigate();
+  const [status, setStatus] = useState({});
+  const logo = getLogo();
 
-    useEffect(() => {
-        if (searchParams.get('expired')) {
-            showError('未登录或登录已过期,请重新登录!');
-        }
-        let status = localStorage.getItem('status');
-        if (status) {
-            status = JSON.parse(status);
-            setStatus(status);
-            if (status.turnstile_check) {
-                setTurnstileEnabled(true);
-                setTurnstileSiteKey(status.turnstile_site_key);
-            }
-        }
-    }, []);
-
-    const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false);
+  useEffect(() => {
+    if (searchParams.get('expired')) {
+      showError('未登录或登录已过期,请重新登录!');
+    }
+    let status = localStorage.getItem('status');
+    if (status) {
+      status = JSON.parse(status);
+      setStatus(status);
+      if (status.turnstile_check) {
+        setTurnstileEnabled(true);
+        setTurnstileSiteKey(status.turnstile_site_key);
+      }
+    }
+  }, []);
 
-    const onWeChatLoginClicked = () => {
-        setShowWeChatLoginModal(true);
-    };
+  const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false);
 
-    const onSubmitWeChatVerificationCode = async () => {
-        if (turnstileEnabled && turnstileToken === '') {
-            showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
-            return;
-        }
-        const res = await API.get(
-            `/api/oauth/wechat?code=${inputs.wechat_verification_code}`
-        );
-        const { success, message, data } = res.data;
-        if (success) {
-            userDispatch({ type: 'login', payload: data });
-            localStorage.setItem('user', JSON.stringify(data));
-            navigate('/');
-            showSuccess('登录成功!');
-            setShowWeChatLoginModal(false);
-        } else {
-            showError(message);
-        }
-    };
+  const onWeChatLoginClicked = () => {
+    setShowWeChatLoginModal(true);
+  };
 
-    function handleChange(name, value) {
-        setInputs((inputs) => ({ ...inputs, [name]: value }));
+  const onSubmitWeChatVerificationCode = async () => {
+    if (turnstileEnabled && turnstileToken === '') {
+      showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
+      return;
     }
+    const res = await API.get(
+      `/api/oauth/wechat?code=${inputs.wechat_verification_code}`
+    );
+    const { success, message, data } = res.data;
+    if (success) {
+      userDispatch({ type: 'login', payload: data });
+      localStorage.setItem('user', JSON.stringify(data));
+      navigate('/');
+      showSuccess('登录成功!');
+      setShowWeChatLoginModal(false);
+    } else {
+      showError(message);
+    }
+  };
 
-    async function handleSubmit(e) {
-        if (turnstileEnabled && turnstileToken === '') {
-            showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
-            return;
-        }
-        setSubmitted(true);
-        if (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 });
-                }
-                navigate('/token');
-            } else {
-                showError(message);
-            }
-        } else {
-            showError('请输入用户名和密码!');
+  function handleChange(name, value) {
+    setInputs((inputs) => ({ ...inputs, [name]: value }));
+  }
+
+  async function handleSubmit(e) {
+    if (turnstileEnabled && turnstileToken === '') {
+      showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
+      return;
+    }
+    setSubmitted(true);
+    if (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 });
         }
+        navigate('/token');
+      } else {
+        showError(message);
+      }
+    } else {
+      showError('请输入用户名和密码!');
     }
+  }
 
-    // 添加Telegram登录处理函数
-    const onTelegramLoginClicked = async (response) => {
-        const fields = ["id", "first_name", "last_name", "username", "photo_url", "auth_date", "hash", "lang"];
-        const params = {};
-        fields.forEach((field) => {
-            if (response[field]) {
-                params[field] = response[field];
-            }
-        });
-        const res = await API.get(`/api/oauth/telegram/login`, { params });
-        const { success, message, data } = res.data;
-        if (success) {
-            userDispatch({ type: 'login', payload: data });
-            localStorage.setItem('user', JSON.stringify(data));
-            showSuccess('登录成功!');
-            navigate('/');
-        } else {
-            showError(message);
-        }
-    };
+  // 添加Telegram登录处理函数
+  const onTelegramLoginClicked = async (response) => {
+    const fields = ['id', 'first_name', 'last_name', 'username', 'photo_url', 'auth_date', 'hash', 'lang'];
+    const params = {};
+    fields.forEach((field) => {
+      if (response[field]) {
+        params[field] = response[field];
+      }
+    });
+    const res = await API.get(`/api/oauth/telegram/login`, { params });
+    const { success, message, data } = res.data;
+    if (success) {
+      userDispatch({ type: 'login', payload: data });
+      localStorage.setItem('user', JSON.stringify(data));
+      showSuccess('登录成功!');
+      navigate('/');
+    } else {
+      showError(message);
+    }
+  };
 
-    return (
-        <div>
-            <Layout>
-                <Layout.Header>
-                </Layout.Header>
-                <Layout.Content>
-                    <div style={{ justifyContent: 'center', display: "flex", marginTop: 120 }}>
-                        <div style={{ width: 500 }}>
-                            <Card>
-                                <Title heading={2} style={{ textAlign: 'center' }}>
-                                    用户登录
-                                </Title>
-                                <Form>
-                                    <Form.Input
-                                        field={'username'}
-                                        label={'用户名'}
-                                        placeholder='用户名'
-                                        name='username'
-                                        onChange={(value) => handleChange('username', value)}
-                                    />
-                                    <Form.Input
-                                        field={'password'}
-                                        label={'密码'}
-                                        placeholder='密码'
-                                        name='password'
-                                        type='password'
-                                        onChange={(value) => handleChange('password', value)}
-                                    />
+  return (
+    <div>
+      <Layout>
+        <Layout.Header>
+        </Layout.Header>
+        <Layout.Content>
+          <div style={{ justifyContent: 'center', display: 'flex', marginTop: 120 }}>
+            <div style={{ width: 500 }}>
+              <Card>
+                <Title heading={2} style={{ textAlign: 'center' }}>
+                  用户登录
+                </Title>
+                <Form>
+                  <Form.Input
+                    field={'username'}
+                    label={'用户名'}
+                    placeholder="用户名"
+                    name="username"
+                    onChange={(value) => handleChange('username', value)}
+                  />
+                  <Form.Input
+                    field={'password'}
+                    label={'密码'}
+                    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>
-                                </Form>
-                                <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 20 }}>
-                                    <Text>
-                                        没有账号请先 <Link to='/register'>注册账号</Link>
-                                    </Text>
-                                    <Text>
-                                        忘记密码 <Link to='/reset'>点击重置</Link>
-                                    </Text>
-                                </div>
-                                {status.github_oauth || status.wechat_login || status.telegram_oauth ? (
-                                    <>
-                                        <Divider margin='12px' align='center'>
-                                            第三方登录
-                                        </Divider>
-                                        <div style={{ display: 'flex', justifyContent: 'center', marginTop: 20 }}>
-                                            {status.github_oauth ? (
-                                                <Button
-                                                    type='primary'
-                                                    icon={<IconGithubLogo />}
-                                                    onClick={() => onGitHubOAuthClicked(status.github_client_id)}
-                                                />
-                                            ) : (
-                                                <></>
-                                            )}
-                                            {status.wechat_login ? (
-                                                <Button
-                                                    type='primary'
-                                                    style={{color: 'rgba(var(--semi-green-5), 1)'}}
-                                                    icon={<Icon svg={<WeChatIcon />} />}
-                                                    onClick={onWeChatLoginClicked}
-                                                />
-                                            ) : (
-                                                <></>
-                                            )}
+                  <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 }}>
+                  <Text>
+                    没有账号请先 <Link to="/register">注册账号</Link>
+                  </Text>
+                  <Text>
+                    忘记密码 <Link to="/reset">点击重置</Link>
+                  </Text>
+                </div>
+                {status.github_oauth || status.wechat_login || status.telegram_oauth ? (
+                  <>
+                    <Divider margin="12px" align="center">
+                      第三方登录
+                    </Divider>
+                    <div style={{ display: 'flex', justifyContent: 'center', marginTop: 20 }}>
+                      {status.github_oauth ? (
+                        <Button
+                          type="primary"
+                          icon={<IconGithubLogo />}
+                          onClick={() => onGitHubOAuthClicked(status.github_client_id)}
+                        />
+                      ) : (
+                        <></>
+                      )}
+                      {status.wechat_login ? (
+                        <Button
+                          type="primary"
+                          style={{ color: 'rgba(var(--semi-green-5), 1)' }}
+                          icon={<Icon svg={<WeChatIcon />} />}
+                          onClick={onWeChatLoginClicked}
+                        />
+                      ) : (
+                        <></>
+                      )}
 
-                                            {status.telegram_oauth ? (
-                                                <TelegramLoginButton dataOnauth={onTelegramLoginClicked} botName={status.telegram_bot_name} />
-                                            ) : (
-                                                <></>
-                                            )}
-                                        </div>
-                                    </>
-                                ) : (
-                                    <></>
-                                )}
-                                <Modal
-                                  title="微信扫码登录"
-                                  visible={showWeChatLoginModal}
-                                  maskClosable={true}
-                                  onOk={onSubmitWeChatVerificationCode}
-                                  onCancel={() => setShowWeChatLoginModal(false)}
-                                  okText={'登录'}
-                                  size={'small'}
-                                  centered={true}
-                                >
-                                    <div style={{ display: 'flex', alignItem: 'center', flexDirection: 'column' }}>
-                                        <img src={status.wechat_qrcode}/>
-                                    </div>
-                                    <div style={{textAlign: 'center'}}>
-                                        <p>
-                                            微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)
-                                        </p>
-                                    </div>
-                                    <Form size='large'>
-                                        <Form.Input
-                                          field={'wechat_verification_code'}
-                                          placeholder='验证码'
-                                          label={'验证码'}
-                                          value={inputs.wechat_verification_code}
-                                          onChange={(value) => handleChange('wechat_verification_code', value)}
-                                        />
-                                    </Form>
-                                </Modal>
-                            </Card>
-                            {turnstileEnabled ? (
-                                <div style={{ display: 'flex', justifyContent: 'center', marginTop: 20 }}>
-                                    <Turnstile
-                                        sitekey={turnstileSiteKey}
-                                        onVerify={(token) => {
-                                            setTurnstileToken(token);
-                                        }}
-                                    />
-                                </div>
-                            ) : (
-                                <></>
-                            )}
-                        </div>
+                      {status.telegram_oauth ? (
+                        <TelegramLoginButton dataOnauth={onTelegramLoginClicked} botName={status.telegram_bot_name} />
+                      ) : (
+                        <></>
+                      )}
                     </div>
+                  </>
+                ) : (
+                  <></>
+                )}
+                <Modal
+                  title="微信扫码登录"
+                  visible={showWeChatLoginModal}
+                  maskClosable={true}
+                  onOk={onSubmitWeChatVerificationCode}
+                  onCancel={() => setShowWeChatLoginModal(false)}
+                  okText={'登录'}
+                  size={'small'}
+                  centered={true}
+                >
+                  <div style={{ display: 'flex', alignItem: 'center', flexDirection: 'column' }}>
+                    <img src={status.wechat_qrcode} />
+                  </div>
+                  <div style={{ textAlign: 'center' }}>
+                    <p>
+                      微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)
+                    </p>
+                  </div>
+                  <Form size="large">
+                    <Form.Input
+                      field={'wechat_verification_code'}
+                      placeholder="验证码"
+                      label={'验证码'}
+                      value={inputs.wechat_verification_code}
+                      onChange={(value) => handleChange('wechat_verification_code', value)}
+                    />
+                  </Form>
+                </Modal>
+              </Card>
+              {turnstileEnabled ? (
+                <div style={{ display: 'flex', justifyContent: 'center', marginTop: 20 }}>
+                  <Turnstile
+                    sitekey={turnstileSiteKey}
+                    onVerify={(token) => {
+                      setTurnstileToken(token);
+                    }}
+                  />
+                </div>
+              ) : (
+                <></>
+              )}
+            </div>
+          </div>
 
-                </Layout.Content>
-            </Layout>
-        </div>
-    );
+        </Layout.Content>
+      </Layout>
+    </div>
+  );
 };
 
 export default LoginForm;

+ 356 - 450
web/src/components/LogsTable.js

@@ -1,493 +1,399 @@
-import React, {useEffect, useState} from 'react';
-import {API, copy, isAdmin, showError, showSuccess, timestamp2string} from '../helpers';
+import React, { useEffect, useState } from 'react';
+import { API, copy, isAdmin, showError, showSuccess, timestamp2string } from '../helpers';
 
-import {Table, Avatar, Tag, Form, Button, Layout, Select, Popover, Modal, Spin, Space} 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";
+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';
 
-const {Header} = Layout;
+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>;
-        case 2:
-            return <Tag color='lime' size='large'> 消费 </Tag>;
-        case 3:
-            return <Tag color='orange' size='large'> 管理 </Tag>;
-        case 4:
-            return <Tag color='purple' size='large'> 系统 </Tag>;
-        default:
-            return <Tag color='black' size='large'> 未知 </Tag>;
-    }
+  switch (type) {
+    case 1:
+      return <Tag color="cyan" size="large"> 充值 </Tag>;
+    case 2:
+      return <Tag color="lime" size="large"> 消费 </Tag>;
+    case 3:
+      return <Tag color="orange" size="large"> 管理 </Tag>;
+    case 4:
+      return <Tag color="purple" size="large"> 系统 </Tag>;
+    default:
+      return <Tag color="black" size="large"> 未知 </Tag>;
+  }
 }
 
 function renderIsStream(bool) {
-    if (bool) {
-        return <Tag color='blue' size='large'>流</Tag>;
-    } else {
-        return <Tag color='purple' size='large'>非流</Tag>;
-    }
+  if (bool) {
+    return <Tag color="blue" size="large">流</Tag>;
+  } else {
+    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>;
-    } else if (time < 300) {
-        return <Tag color='orange' size='large'> {time} s </Tag>;
-    } else {
-        return <Tag color='red' size='large'> {time} s </Tag>;
-    }
+  const time = parseInt(type);
+  if (time < 101) {
+    return <Tag color="green" size="large"> {time} s </Tag>;
+  } else if (time < 300) {
+    return <Tag color="orange" size="large"> {time} s </Tag>;
+  } else {
+    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);
-    const [loading, setLoading] = useState(false);
-    const [loadingStat, setLoadingStat] = useState(false);
-    const [activePage, setActivePage] = useState(1);
-    const [logCount, setLogCount] = useState(ITEMS_PER_PAGE);
-    const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
-    const [searchKeyword, setSearchKeyword] = useState('');
-    const [searching, setSearching] = useState(false);
-    const [logType, setLogType] = useState(0);
-    const isAdminUser = isAdmin();
-    let now = new Date();
-    // 初始化start_timestamp为前一天
-    const [inputs, setInputs] = useState({
-        username: '',
-        token_name: '',
-        model_name: '',
-        start_timestamp: timestamp2string(now.getTime() / 1000 - 86400),
-        end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),
-        channel: ''
-    });
-    const {username, token_name, model_name, start_timestamp, end_timestamp, channel} = inputs;
+  const [logs, setLogs] = useState([]);
+  const [showStat, setShowStat] = useState(false);
+  const [loading, setLoading] = useState(false);
+  const [loadingStat, setLoadingStat] = useState(false);
+  const [activePage, setActivePage] = useState(1);
+  const [logCount, setLogCount] = useState(ITEMS_PER_PAGE);
+  const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
+  const [searchKeyword, setSearchKeyword] = useState('');
+  const [searching, setSearching] = useState(false);
+  const [logType, setLogType] = useState(0);
+  const isAdminUser = isAdmin();
+  let now = new Date();
+  // 初始化start_timestamp为前一天
+  const [inputs, setInputs] = useState({
+    username: '',
+    token_name: '',
+    model_name: '',
+    start_timestamp: timestamp2string(now.getTime() / 1000 - 86400),
+    end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),
+    channel: ''
+  });
+  const { username, token_name, model_name, start_timestamp, end_timestamp, channel } = inputs;
 
-    const [stat, setStat] = useState({
-        quota: 0,
-        token: 0
-    });
+  const [stat, setStat] = useState({
+    quota: 0, token: 0
+  });
 
-    const handleInputChange = (value, name) => {
-        setInputs((inputs) => ({...inputs, [name]: value}));
-    };
+  const handleInputChange = (value, name) => {
+    setInputs((inputs) => ({ ...inputs, [name]: value }));
+  };
 
-    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}`);
-        const {success, message, data} = res.data;
-        if (success) {
-            setStat(data);
-        } else {
-            showError(message);
-        }
-    };
+  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}`);
+    const { success, message, data } = res.data;
+    if (success) {
+      setStat(data);
+    } else {
+      showError(message);
+    }
+  };
 
-    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}`);
-        const {success, message, data} = res.data;
-        if (success) {
-            setStat(data);
-        } else {
-            showError(message);
-        }
-    };
+  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}`);
+    const { success, message, data } = res.data;
+    if (success) {
+      setStat(data);
+    } else {
+      showError(message);
+    }
+  };
 
-    const handleEyeClick = async () => {
-        setLoadingStat(true);
-        if (isAdminUser) {
-            await getLogStat();
-        } else {
-            await getLogSelfStat();
-        }
-        setShowStat(true);
-        setLoadingStat(false);
-    };
+  const handleEyeClick = async () => {
+    setLoadingStat(true);
+    if (isAdminUser) {
+      await getLogStat();
+    } else {
+      await getLogSelfStat();
+    }
+    setShowStat(true);
+    setLoadingStat(false);
+  };
 
-    const showUserInfo = async (userId) => {
-        if (!isAdminUser) {
-            return;
-        }
-        const res = await API.get(`/api/user/${userId}`);
-        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,
-            })
-        } else {
-            showError(message);
-        }
-    };
+  const showUserInfo = async (userId) => {
+    if (!isAdminUser) {
+      return;
+    }
+    const res = await API.get(`/api/user/${userId}`);
+    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
+      });
+    } else {
+      showError(message);
+    }
+  };
 
-    const setLogsFormat = (logs) => {
-        for (let i = 0; i < logs.length; i++) {
-            logs[i].timestamp2string = timestamp2string(logs[i].created_at);
-            logs[i].key = '' + logs[i].id;
-        }
-        // data.key = '' + data.id
-        setLogs(logs);
-        setLogCount(logs.length + ITEMS_PER_PAGE);
-        // console.log(logCount);
+  const setLogsFormat = (logs) => {
+    for (let i = 0; i < logs.length; i++) {
+      logs[i].timestamp2string = timestamp2string(logs[i].created_at);
+      logs[i].key = '' + logs[i].id;
     }
+    // data.key = '' + data.id
+    setLogs(logs);
+    setLogCount(logs.length + ITEMS_PER_PAGE);
+    // console.log(logCount);
+  };
 
-    const loadLogs = async (startIdx, pageSize) => {
-        setLoading(true);
+  const loadLogs = async (startIdx, pageSize, logType = 0) => {
+    setLoading(true);
 
-        let url = '';
-        let localStartTimestamp = Date.parse(start_timestamp) / 1000;
-        let localEndTimestamp = Date.parse(end_timestamp) / 1000;
-        if (isAdminUser) {
-            url = `/api/log/?p=${startIdx}&page_size=${pageSize}&type=${logType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}`;
-        } else {
-            url = `/api/log/self/?p=${startIdx}&page_size=${pageSize}&type=${logType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
-        }
-        const res = await API.get(url);
-        const {success, message, data} = res.data;
-        if (success) {
-            if (startIdx === 0) {
-                setLogsFormat(data);
-            } else {
-                let newLogs = [...logs];
-                newLogs.splice(startIdx * pageSize, data.length, ...data);
-                setLogsFormat(newLogs);
-            }
-        } 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/log/?p=${startIdx}&page_size=${pageSize}&type=${logType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}`;
+    } else {
+      url = `/api/log/self/?p=${startIdx}&page_size=${pageSize}&type=${logType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
+    }
+    const res = await API.get(url);
+    const { success, message, data } = res.data;
+    if (success) {
+      if (startIdx === 0) {
+        setLogsFormat(data);
+      } else {
+        let newLogs = [...logs];
+        newLogs.splice(startIdx * pageSize, data.length, ...data);
+        setLogsFormat(newLogs);
+      }
+    } else {
+      showError(message);
+    }
+    setLoading(false);
+  };
 
-    const pageData = logs.slice((activePage - 1) * pageSize, activePage * pageSize);
+  const pageData = logs.slice((activePage - 1) * pageSize, activePage * pageSize);
 
-    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).then(r => {
-            });
-        }
-    };
+  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).then(r => {
+      });
+    }
+  };
 
-    const handlePageSizeChange = async (size) => {
-        localStorage.setItem('page-size', size + '')
-        setPageSize(size)
-        setActivePage(1)
-        loadLogs(0, size)
-            .then()
-            .catch((reason) => {
-                showError(reason);
-            })
-    };
+  const handlePageSizeChange = async (size) => {
+    localStorage.setItem('page-size', size + '');
+    setPageSize(size);
+    setActivePage(1);
+    loadLogs(0, size)
+      .then()
+      .catch((reason) => {
+        showError(reason);
+      });
+  };
 
-    const refresh = async () => {
-        // setLoading(true);
-        setActivePage(1);
-        await loadLogs(0, pageSize);
-    };
+  const refresh = async (localLogType) => {
+    // setLoading(true);
+    setActivePage(1);
+    await loadLogs(0, pageSize, localLogType);
+  };
 
-    const copyText = async (text) => {
-        if (await copy(text)) {
-            showSuccess('已复制:' + text);
-        } else {
-            // setSearchKeyword(text);
-            Modal.error({title: '无法复制到剪贴板,请手动复制', content: text});
-        }
+  const copyText = async (text) => {
+    if (await copy(text)) {
+      showSuccess('已复制:' + text);
+    } else {
+      // setSearchKeyword(text);
+      Modal.error({ title: '无法复制到剪贴板,请手动复制', content: text });
     }
+  };
 
-    // useEffect(() => {
-    //     refresh().then();
-    // }, [logType]);
+  useEffect(() => {
+    // console.log('default effect')
+    const localPageSize = parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
+    setPageSize(localPageSize);
+    loadLogs(0, localPageSize)
+      .then()
+      .catch((reason) => {
+        showError(reason);
+      });
+  }, []);
 
-    useEffect(() => {
-        // console.log('default effect')
-        const localPageSize = parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
-        setPageSize(localPageSize)
-        loadLogs(0, localPageSize)
-            .then()
-            .catch((reason) => {
-                showError(reason);
-            });
-    }, []);
-
-    const searchLogs = async () => {
-        if (searchKeyword === '') {
-            // if keyword is blank, load files instead.
-            await loadLogs(0, pageSize);
-            setActivePage(1);
-            return;
-        }
-        setSearching(true);
-        const res = await API.get(`/api/log/self/search?keyword=${searchKeyword}`);
-        const {success, message, data} = res.data;
-        if (success) {
-            setLogs(data);
-            setActivePage(1);
-        } else {
-            showError(message);
-        }
-        setSearching(false);
-    };
+  const searchLogs = async () => {
+    if (searchKeyword === '') {
+      // if keyword is blank, load files instead.
+      await loadLogs(0, pageSize);
+      setActivePage(1);
+      return;
+    }
+    setSearching(true);
+    const res = await API.get(`/api/log/self/search?keyword=${searchKeyword}`);
+    const { success, message, data } = res.data;
+    if (success) {
+      setLogs(data);
+      setActivePage(1);
+    } else {
+      showError(message);
+    }
+    setSearching(false);
+  };
 
-    return (
+  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 }}>
         <>
-            <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));
-                    }
-                }>
-                    <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>
+          <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;

+ 407 - 419
web/src/components/MjLogsTable.js

@@ -1,454 +1,442 @@
-import React, {useEffect, useState} from 'react';
-import {API, copy, isAdmin, showError, showSuccess, timestamp2string} from '../helpers';
-
-import {
-    Table,
-    Avatar,
-    Tag,
-    Form,
-    Button,
-    Layout,
-    Select,
-    Popover,
-    Modal,
-    ImagePreview,
-    Typography, Progress
-} from '@douyinfe/semi-ui';
-import {ITEMS_PER_PAGE} from '../constants';
-import {renderNumber, renderQuota, stringToColor} from '../helpers/render';
+import React, { useEffect, useState } from 'react';
+import { API, copy, isAdmin, showError, showSuccess, timestamp2string } from '../helpers';
+
+import { 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'
-]
+  'light-blue', 'lime', 'orange', 'pink',
+  'purple', 'red', 'teal', 'violet', 'yellow'
+];
 
 function renderType(type) {
-    switch (type) {
-        case 'IMAGINE':
-            return <Tag color="blue" size='large'>绘图</Tag>;
-        case 'UPSCALE':
-            return <Tag color="orange" size='large'>放大</Tag>;
-        case 'VARIATION':
-            return <Tag color="purple" size='large'>变换</Tag>;
-        case 'HIGH_VARIATION':
-            return <Tag color="purple" size='large'>强变换</Tag>;
-        case 'LOW_VARIATION':
-            return <Tag color="purple" size='large'>弱变换</Tag>;
-        case 'PAN':
-            return <Tag color="cyan" size='large'>平移</Tag>;
-        case 'DESCRIBE':
-            return <Tag color="yellow" size='large'>图生文</Tag>;
-        case 'BLEND':
-            return <Tag color="lime" size='large'>图混合</Tag>;
-        case 'SHORTEN':
-            return <Tag color="pink" size='large'>缩词</Tag>;
-        case 'REROLL':
-            return <Tag color="indigo" size='large'>重绘</Tag>;
-        case 'INPAINT':
-            return <Tag color="violet" size='large'>局部重绘-提交</Tag>;
-        case 'ZOOM':
-            return <Tag color="teal" size='large'>变焦</Tag>;
-        case 'CUSTOM_ZOOM':
-            return <Tag color="teal" size='large'>自定义变焦-提交</Tag>;
-        case 'MODAL':
-            return <Tag color="green" size='large'>窗口处理</Tag>;
-        case 'SWAP_FACE':
-            return <Tag color="light-green" size='large'>换脸</Tag>;
-        default:
-            return <Tag color="white" size='large'>未知</Tag>;
-    }
+  switch (type) {
+    case 'IMAGINE':
+      return <Tag color="blue" size="large">绘图</Tag>;
+    case 'UPSCALE':
+      return <Tag color="orange" size="large">放大</Tag>;
+    case 'VARIATION':
+      return <Tag color="purple" size="large">变换</Tag>;
+    case 'HIGH_VARIATION':
+      return <Tag color="purple" size="large">强变换</Tag>;
+    case 'LOW_VARIATION':
+      return <Tag color="purple" size="large">弱变换</Tag>;
+    case 'PAN':
+      return <Tag color="cyan" size="large">平移</Tag>;
+    case 'DESCRIBE':
+      return <Tag color="yellow" size="large">图生文</Tag>;
+    case 'BLEND':
+      return <Tag color="lime" size="large">图混合</Tag>;
+    case 'SHORTEN':
+      return <Tag color="pink" size="large">缩词</Tag>;
+    case 'REROLL':
+      return <Tag color="indigo" size="large">重绘</Tag>;
+    case 'INPAINT':
+      return <Tag color="violet" size="large">局部重绘-提交</Tag>;
+    case 'ZOOM':
+      return <Tag color="teal" size="large">变焦</Tag>;
+    case 'CUSTOM_ZOOM':
+      return <Tag color="teal" size="large">自定义变焦-提交</Tag>;
+    case 'MODAL':
+      return <Tag color="green" size="large">窗口处理</Tag>;
+    case 'SWAP_FACE':
+      return <Tag color="light-green" size="large">换脸</Tag>;
+    default:
+      return <Tag color="white" size="large">未知</Tag>;
+  }
 }
 
 
 function renderCode(code) {
-    switch (code) {
-        case 1:
-            return <Tag color="green" size='large'>已提交</Tag>;
-        case 21:
-            return <Tag color="lime" size='large'>等待中</Tag>;
-        case 22:
-            return <Tag color="orange" size='large'>重复提交</Tag>;
-        case 0:
-            return <Tag color="yellow" size='large'>未提交</Tag>;
-        default:
-            return <Tag color="white" size='large'>未知</Tag>;
-    }
+  switch (code) {
+    case 1:
+      return <Tag color="green" size="large">已提交</Tag>;
+    case 21:
+      return <Tag color="lime" size="large">等待中</Tag>;
+    case 22:
+      return <Tag color="orange" size="large">重复提交</Tag>;
+    case 0:
+      return <Tag color="yellow" size="large">未提交</Tag>;
+    default:
+      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>;
-        case 'NOT_START':
-            return <Tag color="grey" size='large'>未启动</Tag>;
-        case 'SUBMITTED':
-            return <Tag color="yellow" size='large'>队列中</Tag>;
-        case 'IN_PROGRESS':
-            return <Tag color="blue" size='large'>执行中</Tag>;
-        case 'FAILURE':
-            return <Tag color="red" size='large'>失败</Tag>;
-        case 'MODAL':
-            return <Tag color="yellow" size='large'>窗口等待</Tag>;
-        default:
-            return <Tag color="white" size='large'>未知</Tag>;
-    }
+  // Ensure all cases are string literals by adding quotes.
+  switch (type) {
+    case 'SUCCESS':
+      return <Tag color="green" size="large">成功</Tag>;
+    case 'NOT_START':
+      return <Tag color="grey" size="large">未启动</Tag>;
+    case 'SUBMITTED':
+      return <Tag color="yellow" size="large">队列中</Tag>;
+    case 'IN_PROGRESS':
+      return <Tag color="blue" size="large">执行中</Tag>;
+    case 'FAILURE':
+      return <Tag color="red" size="large">失败</Tag>;
+    case 'MODAL':
+      return <Tag color="yellow" size="large">窗口等待</Tag>;
+    default:
+      return <Tag color="white" size="large">未知</Tag>;
+  }
 }
 
 const renderTimestamp = (timestampInSeconds) => {
-    const date = new Date(timestampInSeconds * 1000); // 从秒转换为毫秒
+  const date = new Date(timestampInSeconds * 1000); // 从秒转换为毫秒
 
-    const year = date.getFullYear(); // 获取年份
-    const month = ('0' + (date.getMonth() + 1)).slice(-2); // 获取月份,从0开始需要+1,并保证两位数
-    const day = ('0' + date.getDate()).slice(-2); // 获取日期,并保证两位数
-    const hours = ('0' + date.getHours()).slice(-2); // 获取小时,并保证两位数
-    const minutes = ('0' + date.getMinutes()).slice(-2); // 获取分钟,并保证两位数
-    const seconds = ('0' + date.getSeconds()).slice(-2); // 获取秒钟,并保证两位数
+  const year = date.getFullYear(); // 获取年份
+  const month = ('0' + (date.getMonth() + 1)).slice(-2); // 获取月份,从0开始需要+1,并保证两位数
+  const day = ('0' + date.getDate()).slice(-2); // 获取日期,并保证两位数
+  const hours = ('0' + date.getHours()).slice(-2); // 获取小时,并保证两位数
+  const minutes = ('0' + date.getMinutes()).slice(-2); // 获取分钟,并保证两位数
+  const seconds = ('0' + date.getSeconds()).slice(-2); // 获取秒钟,并保证两位数
 
-    return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; // 格式化输出
+  return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; // 格式化输出
 };
 
 
 const LogsTable = () => {
-    const [isModalOpen, setIsModalOpen] = useState(false);
-    const [modalContent, setModalContent] = useState('');
-    const columns = [
-        {
-            title: '提交时间',
-            dataIndex: 'submit_time',
-            render: (text, record, index) => {
-                return (
-                    <div>
-                        {renderTimestamp(text / 1000)}
-                    </div>
-                );
-            },
-        },
-        {
-            title: '渠道',
-            dataIndex: 'channel_id',
-            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>
-                    </div>
-
-                );
-            },
-        },
-        {
-            title: '类型',
-            dataIndex: 'action',
-            render: (text, record, index) => {
-                return (
-                    <div>
-                        {renderType(text)}
-                    </div>
-                );
-            },
-        },
-        {
-            title: '任务ID',
-            dataIndex: 'mj_id',
-            render: (text, record, index) => {
-                return (
-                    <div>
-                        {text}
-                    </div>
-                );
-            },
-        },
-        {
-            title: '提交结果',
-            dataIndex: 'code',
-            className: isAdmin() ? 'tableShow' : 'tableHiddle',
-            render: (text, record, index) => {
-                return (
-                    <div>
-                        {renderCode(text)}
-                    </div>
-                );
-            },
-        },
-        {
-            title: '任务状态',
-            dataIndex: 'status',
-            className: isAdmin() ? 'tableShow' : 'tableHiddle',
-            render: (text, record, index) => {
-                return (
-                    <div>
-                        {renderStatus(text)}
-                    </div>
-                );
-            },
-        },
-        {
-            title: '进度',
-            dataIndex: 'progress',
-            render: (text, record, index) => {
-                return (
-                    <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"/>
-                        }
-                    </div>
-                );
-            },
-        },
-        {
-            title: '结果图片',
-            dataIndex: 'image_url',
-            render: (text, record, index) => {
-                if (!text) {
-                    return '无';
-                }
-                return (
-                    <Button
-                        onClick={() => {
-                            setModalImageUrl(text);  // 更新图片URL状态
-                            setIsModalOpenurl(true);    // 打开模态框
-                        }}
-                    >
-                        查看图片
-                    </Button>
-                );
-            }
-        },
-        {
-            title: 'Prompt',
-            dataIndex: 'prompt',
-            render: (text, record, index) => {
-                // 如果text未定义,返回替代文本,例如空字符串''或其他
-                if (!text) {
-                    return '无';
-                }
-
-                return (
-                    <Typography.Text
-                        ellipsis={{showTooltip: true}}
-                        style={{width: 100}}
-                        onClick={() => {
-                            setModalContent(text);
-                            setIsModalOpen(true);
-                        }}
-                    >
-                        {text}
-                    </Typography.Text>
-                );
-            }
-        },
-        {
-            title: 'PromptEn',
-            dataIndex: 'prompt_en',
-            render: (text, record, index) => {
-                // 如果text未定义,返回替代文本,例如空字符串''或其他
-                if (!text) {
-                    return '无';
-                }
-
-                return (
-                    <Typography.Text
-                        ellipsis={{showTooltip: true}}
-                        style={{width: 100}}
-                        onClick={() => {
-                            setModalContent(text);
-                            setIsModalOpen(true);
-                        }}
-                    >
-                        {text}
-                    </Typography.Text>
-                );
-            }
-        },
-        {
-            title: '失败原因',
-            dataIndex: 'fail_reason',
-            render: (text, record, index) => {
-                // 如果text未定义,返回替代文本,例如空字符串''或其他
-                if (!text) {
-                    return '无';
-                }
-
-                return (
-                    <Typography.Text
-                        ellipsis={{showTooltip: true}}
-                        style={{width: 100}}
-                        onClick={() => {
-                            setModalContent(text);
-                            setIsModalOpen(true);
-                        }}
-                    >
-                        {text}
-                    </Typography.Text>
-                );
+  const [isModalOpen, setIsModalOpen] = useState(false);
+  const [modalContent, setModalContent] = useState('');
+  const columns = [
+    {
+      title: '提交时间',
+      dataIndex: 'submit_time',
+      render: (text, record, index) => {
+        return (
+          <div>
+            {renderTimestamp(text / 1000)}
+          </div>
+        );
+      }
+    },
+    {
+      title: '渠道',
+      dataIndex: 'channel_id',
+      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>
+          </div>
+
+        );
+      }
+    },
+    {
+      title: '类型',
+      dataIndex: 'action',
+      render: (text, record, index) => {
+        return (
+          <div>
+            {renderType(text)}
+          </div>
+        );
+      }
+    },
+    {
+      title: '任务ID',
+      dataIndex: 'mj_id',
+      render: (text, record, index) => {
+        return (
+          <div>
+            {text}
+          </div>
+        );
+      }
+    },
+    {
+      title: '提交结果',
+      dataIndex: 'code',
+      className: isAdmin() ? 'tableShow' : 'tableHiddle',
+      render: (text, record, index) => {
+        return (
+          <div>
+            {renderCode(text)}
+          </div>
+        );
+      }
+    },
+    {
+      title: '任务状态',
+      dataIndex: 'status',
+      className: isAdmin() ? 'tableShow' : 'tableHiddle',
+      render: (text, record, index) => {
+        return (
+          <div>
+            {renderStatus(text)}
+          </div>
+        );
+      }
+    },
+    {
+      title: '进度',
+      dataIndex: 'progress',
+      render: (text, record, index) => {
+        return (
+          <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" />
             }
+          </div>
+        );
+      }
+    },
+    {
+      title: '结果图片',
+      dataIndex: 'image_url',
+      render: (text, record, index) => {
+        if (!text) {
+          return '无';
         }
-
-    ];
-
-    const [logs, setLogs] = useState([]);
-    const [loading, setLoading] = useState(true);
-    const [activePage, setActivePage] = useState(1);
-    const [logCount, setLogCount] = useState(ITEMS_PER_PAGE);
-    const [logType, setLogType] = useState(0);
-    const isAdminUser = isAdmin();
-    const [isModalOpenurl, setIsModalOpenurl] = useState(false);
-
-    // 定义模态框图片URL的状态和更新函数
-    const [modalImageUrl, setModalImageUrl] = useState('');
-    let now = new Date();
-    // 初始化start_timestamp为前一天
-    const [inputs, setInputs] = useState({
-        channel_id: '',
-        mj_id: '',
-        start_timestamp: timestamp2string(now.getTime() / 1000 - 2592000),
-        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
-    });
-
-    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);
-            logs[i].key = '' + logs[i].id;
+        return (
+          <Button
+            onClick={() => {
+              setModalImageUrl(text);  // 更新图片URL状态
+              setIsModalOpenurl(true);    // 打开模态框
+            }}
+          >
+            查看图片
+          </Button>
+        );
+      }
+    },
+    {
+      title: 'Prompt',
+      dataIndex: 'prompt',
+      render: (text, record, index) => {
+        // 如果text未定义,返回替代文本,例如空字符串''或其他
+        if (!text) {
+          return '无';
         }
-        // data.key = '' + data.id
-        setLogs(logs);
-        setLogCount(logs.length + ITEMS_PER_PAGE);
-        // console.log(logCount);
-    }
-
-    const loadLogs = async (startIdx) => {
-        setLoading(true);
 
-        let url = '';
-        let localStartTimestamp = Date.parse(start_timestamp);
-        let localEndTimestamp = Date.parse(end_timestamp);
-        if (isAdminUser) {
-            url = `/api/mj/?p=${startIdx}&channel_id=${channel_id}&mj_id=${mj_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
-        } else {
-            url = `/api/mj/self/?p=${startIdx}&mj_id=${mj_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
+        return (
+          <Typography.Text
+            ellipsis={{ showTooltip: true }}
+            style={{ width: 100 }}
+            onClick={() => {
+              setModalContent(text);
+              setIsModalOpen(true);
+            }}
+          >
+            {text}
+          </Typography.Text>
+        );
+      }
+    },
+    {
+      title: 'PromptEn',
+      dataIndex: 'prompt_en',
+      render: (text, record, index) => {
+        // 如果text未定义,返回替代文本,例如空字符串''或其他
+        if (!text) {
+          return '无';
         }
-        const res = await API.get(url);
-        const {success, message, data} = res.data;
-        if (success) {
-            if (startIdx === 0) {
-                setLogsFormat(data);
-            } else {
-                let newLogs = [...logs];
-                newLogs.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data);
-                setLogsFormat(newLogs);
-            }
-        } else {
-            showError(message);
+
+        return (
+          <Typography.Text
+            ellipsis={{ showTooltip: true }}
+            style={{ width: 100 }}
+            onClick={() => {
+              setModalContent(text);
+              setIsModalOpen(true);
+            }}
+          >
+            {text}
+          </Typography.Text>
+        );
+      }
+    },
+    {
+      title: '失败原因',
+      dataIndex: 'fail_reason',
+      render: (text, record, index) => {
+        // 如果text未定义,返回替代文本,例如空字符串''或其他
+        if (!text) {
+          return '无';
         }
-        setLoading(false);
-    };
 
-    const pageData = logs.slice((activePage - 1) * ITEMS_PER_PAGE, activePage * ITEMS_PER_PAGE);
+        return (
+          <Typography.Text
+            ellipsis={{ showTooltip: true }}
+            style={{ width: 100 }}
+            onClick={() => {
+              setModalContent(text);
+              setIsModalOpen(true);
+            }}
+          >
+            {text}
+          </Typography.Text>
+        );
+      }
+    }
 
-    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 => {
-            });
-        }
-    };
-
-    const refresh = async () => {
-        // setLoading(true);
-        setActivePage(1);
-        await loadLogs(0);
-    };
-
-    const copyText = async (text) => {
-        if (await copy(text)) {
-            showSuccess('已复制:' + text);
-        } else {
-            // setSearchKeyword(text);
-            Modal.error({title: '无法复制到剪贴板,请手动复制', content: text});
-        }
+  ];
+
+  const [logs, setLogs] = useState([]);
+  const [loading, setLoading] = useState(true);
+  const [activePage, setActivePage] = useState(1);
+  const [logCount, setLogCount] = useState(ITEMS_PER_PAGE);
+  const [logType, setLogType] = useState(0);
+  const isAdminUser = isAdmin();
+  const [isModalOpenurl, setIsModalOpenurl] = useState(false);
+
+  // 定义模态框图片URL的状态和更新函数
+  const [modalImageUrl, setModalImageUrl] = useState('');
+  let now = new Date();
+  // 初始化start_timestamp为前一天
+  const [inputs, setInputs] = useState({
+    channel_id: '',
+    mj_id: '',
+    start_timestamp: timestamp2string(now.getTime() / 1000 - 2592000),
+    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
+  });
+
+  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);
+      logs[i].key = '' + logs[i].id;
     }
+    // data.key = '' + data.id
+    setLogs(logs);
+    setLogCount(logs.length + ITEMS_PER_PAGE);
+    // console.log(logCount);
+  };
+
+  const loadLogs = async (startIdx) => {
+    setLoading(true);
+
+    let url = '';
+    let localStartTimestamp = Date.parse(start_timestamp);
+    let localEndTimestamp = Date.parse(end_timestamp);
+    if (isAdminUser) {
+      url = `/api/mj/?p=${startIdx}&channel_id=${channel_id}&mj_id=${mj_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
+    } else {
+      url = `/api/mj/self/?p=${startIdx}&mj_id=${mj_id}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}`;
+    }
+    const res = await API.get(url);
+    const { success, message, data } = res.data;
+    if (success) {
+      if (startIdx === 0) {
+        setLogsFormat(data);
+      } else {
+        let newLogs = [...logs];
+        newLogs.splice(startIdx * ITEMS_PER_PAGE, data.length, ...data);
+        setLogsFormat(newLogs);
+      }
+    } else {
+      showError(message);
+    }
+    setLoading(false);
+  };
+
+  const pageData = logs.slice((activePage - 1) * ITEMS_PER_PAGE, activePage * ITEMS_PER_PAGE);
 
-    useEffect(() => {
-        refresh().then();
-    }, [logType]);
-
-
-    return (
-        <>
-
-            <Layout>
-                <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.Section>
-                            <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}/>
-                <Modal
-                    visible={isModalOpen}
-                    onOk={() => setIsModalOpen(false)}
-                    onCancel={() => setIsModalOpen(false)}
-                    closable={null}
-                    bodyStyle={{height: '400px', overflow: 'auto'}} // 设置模态框内容区域样式
-                    width={800} // 设置模态框宽度
-                >
-                    <p style={{whiteSpace: 'pre-line'}}>{modalContent}</p>
-                </Modal>
-                <ImagePreview
-                    src={modalImageUrl}
-                    visible={isModalOpenurl}
-                    onVisibleChange={(visible) => setIsModalOpenurl(visible)}
-                />
-
-            </Layout>
-        </>
-    );
+  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 => {
+      });
+    }
+  };
+
+  const refresh = async () => {
+    // setLoading(true);
+    setActivePage(1);
+    await loadLogs(0);
+  };
+
+  const copyText = async (text) => {
+    if (await copy(text)) {
+      showSuccess('已复制:' + text);
+    } else {
+      // setSearchKeyword(text);
+      Modal.error({ title: '无法复制到剪贴板,请手动复制', content: text });
+    }
+  };
+
+  useEffect(() => {
+    refresh().then();
+  }, [logType]);
+
+
+  return (
+    <>
+
+      <Layout>
+        <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.Section>
+              <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} />
+        <Modal
+          visible={isModalOpen}
+          onOk={() => setIsModalOpen(false)}
+          onCancel={() => setIsModalOpen(false)}
+          closable={null}
+          bodyStyle={{ height: '400px', overflow: 'auto' }} // 设置模态框内容区域样式
+          width={800} // 设置模态框宽度
+        >
+          <p style={{ whiteSpace: 'pre-line' }}>{modalContent}</p>
+        </Modal>
+        <ImagePreview
+          src={modalImageUrl}
+          visible={isModalOpenurl}
+          onVisibleChange={(visible) => setIsModalOpenurl(visible)}
+        />
+
+      </Layout>
+    </>
+  );
 };
 
 export default LogsTable;

+ 452 - 452
web/src/components/OperationSetting.js

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

+ 29 - 28
web/src/components/OtherSetting.js

@@ -1,5 +1,5 @@
 import React, { useEffect, useRef, useState } from 'react';
-import { Col, Row , Form, Button, Banner } from '@douyinfe/semi-ui';
+import { Banner, Button, Col, Form, Row } from '@douyinfe/semi-ui';
 import { API, showError, showSuccess } from '../helpers';
 import { marked } from 'marked';
 
@@ -57,8 +57,8 @@ const OtherSetting = () => {
       await updateOption('Notice', inputs.Notice);
       showSuccess('公告已更新');
     } catch (error) {
-      console.error("公告更新失败", error);
-      showError("公告更新失败")
+      console.error('公告更新失败', error);
+      showError('公告更新失败');
     } finally {
       setLoadingInput((loadingInput) => ({ ...loadingInput, Notice: false }));
     }
@@ -72,8 +72,8 @@ const OtherSetting = () => {
       await updateOption('SystemName', inputs.SystemName);
       showSuccess('系统名称已更新');
     } catch (error) {
-      console.error("系统名称更新失败", error);
-      showError("系统名称更新失败")
+      console.error('系统名称更新失败', error);
+      showError('系统名称更新失败');
     } finally {
       setLoadingInput((loadingInput) => ({ ...loadingInput, SystemName: false }));
     }
@@ -86,8 +86,8 @@ const OtherSetting = () => {
       await updateOption('Logo', inputs.Logo);
       showSuccess('Logo 已更新');
     } catch (error) {
-      console.error("Logo 更新失败", error);
-      showError("Logo 更新失败")
+      console.error('Logo 更新失败', error);
+      showError('Logo 更新失败');
     } finally {
       setLoadingInput((loadingInput) => ({ ...loadingInput, Logo: false }));
     }
@@ -99,8 +99,8 @@ const OtherSetting = () => {
       await updateOption(key, inputs[key]);
       showSuccess('首页内容已更新');
     } catch (error) {
-      console.error("首页内容更新失败", error);
-      showError("首页内容更新失败")
+      console.error('首页内容更新失败', error);
+      showError('首页内容更新失败');
     } finally {
       setLoadingInput((loadingInput) => ({ ...loadingInput, HomePageContent: false }));
     }
@@ -112,8 +112,8 @@ const OtherSetting = () => {
       await updateOption('About', inputs.About);
       showSuccess('关于内容已更新');
     } catch (error) {
-      console.error("关于内容更新失败", error);
-      showError("关于内容更新失败");
+      console.error('关于内容更新失败', error);
+      showError('关于内容更新失败');
     } finally {
       setLoadingInput((loadingInput) => ({ ...loadingInput, About: false }));
     }
@@ -125,16 +125,14 @@ const OtherSetting = () => {
       await updateOption('Footer', inputs.Footer);
       showSuccess('页脚内容已更新');
     } catch (error) {
-      console.error("页脚内容更新失败", error);
-      showError("页脚内容更新失败");
+      console.error('页脚内容更新失败', error);
+      showError('页脚内容更新失败');
     } finally {
       setLoadingInput((loadingInput) => ({ ...loadingInput, Footer: false }));
     }
   };
 
 
-
-
   const openGitHubRelease = () => {
     window.location =
       'https://github.com/songquanpeng/one-api/releases/latest';
@@ -173,16 +171,17 @@ const OtherSetting = () => {
     }
   };
 
-  useEffect( () => {
+  useEffect(() => {
     getOptions();
   }, []);
 
 
   return (
-    <Row >
+    <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,26 +190,27 @@ const OtherSetting = () => {
               onChange={handleInputChange}
               style={{ fontFamily: 'JetBrains Mono, Consolas' }}
               autosize={{ minRows: 6, maxRows: 12 }}
-              />
+            />
             <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={'系统名称'}
               placeholder={'在此输入系统名称'}
               field={'SystemName'}
               onChange={handleInputChange}
-              />
+            />
             <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>
             <Form.TextArea
               label={'首页内容'}
@@ -219,8 +219,9 @@ const OtherSetting = () => {
               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 属性,这允许你设置任意网页作为关于页面。'}
@@ -228,7 +229,7 @@ const OtherSetting = () => {
               onChange={handleInputChange}
               style={{ fontFamily: 'JetBrains Mono, Consolas' }}
               autosize={{ minRows: 6, maxRows: 12 }}
-              />
+            />
             <Button onClick={submitAbout} loading={loadingInput['About']}>设置关于</Button>
             {/*  */}
             <Banner
@@ -236,14 +237,14 @@ const OtherSetting = () => {
               type="info"
               description="移除 One API 的版权标识必须首先获得授权,项目维护需要花费大量精力,如果本项目对你有意义,请主动支持本项目。"
               closeIcon={null}
-              style={{  marginTop: 15 }}
+              style={{ marginTop: 15 }}
             />
             <Form.Input
               label={'页脚'}
               placeholder={'在此输入新的页脚,留空则使用默认页脚,支持 HTML 代码'}
               field={'Footer'}
               onChange={handleInputChange}
-              />
+            />
             <Button onClick={submitFooter} loading={loadingInput['Footer']}>设置页脚</Button>
           </Form.Section>
         </Form>
@@ -270,7 +271,7 @@ const OtherSetting = () => {
       {/*    />*/}
       {/*  </Modal.Actions>*/}
       {/*</Modal>*/}
-   </Row>
+    </Row>
   );
 };
 

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

@@ -1,12 +1,12 @@
 import React, { useEffect, useState } from 'react';
 import { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react';
-import { API, copy, showError, showInfo, showNotice, showSuccess } from '../helpers';
+import { API, copy, showError, showNotice } from '../helpers';
 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
     });
   }, []);
 
@@ -37,7 +37,7 @@ const PasswordResetConfirm = () => {
       setDisableButton(false);
       setCountdown(30);
     }
-    return () => clearInterval(countdownInterval); 
+    return () => clearInterval(countdownInterval);
   }, [disableButton, countdown]);
 
   async function handleSubmit(e) {
@@ -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) {
@@ -59,44 +59,44 @@ const PasswordResetConfirm = () => {
     }
     setLoading(false);
   }
-  
+
   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'
-              value={newPassword}
-              readOnly
-              onClick={(e) => {
-                e.target.select();
-                navigator.clipboard.writeText(newPassword);
-                showNotice(`密码已复制到剪贴板:${newPassword}`);
-              }}
-            />            
+                fluid
+                icon="lock"
+                iconPosition="left"
+                placeholder="新密码"
+                name="newPassword"
+                value={newPassword}
+                readOnly
+                onClick={(e) => {
+                  e.target.select();
+                  navigator.clipboard.writeText(newPassword);
+                  showNotice(`密码已复制到剪贴板:${newPassword}`);
+                }}
+              />
             )}
             <Button
-              color='green'
+              color="green"
               fluid
-              size='large'
+              size="large"
               onClick={handleSubmit}
               loading={loading}
               disabled={disableButton}
@@ -107,7 +107,7 @@ const PasswordResetConfirm = () => {
         </Form>
       </Grid.Column>
     </Grid>
-  );  
+  );
 };
 
 export default PasswordResetConfirm;

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

@@ -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}

+ 601 - 594
web/src/components/PersonalSetting.js

@@ -1,637 +1,644 @@
-import React, {useContext, useEffect, useState} from 'react';
-import {Link, useNavigate} from 'react-router-dom';
-import {API, copy, isRoot, showError, showInfo, showNotice, showSuccess} from '../helpers';
+import React, { useContext, useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { API, copy, isRoot, showError, showInfo, showSuccess } from '../helpers';
 import Turnstile from 'react-turnstile';
-import {UserContext} from '../context/User';
-import {onGitHubOAuthClicked} from './utils';
+import { UserContext } from '../context/User';
+import { onGitHubOAuthClicked } from './utils';
 import {
-    Avatar, Banner,
-    Button,
-    Card,
-    Descriptions,
-    Divider, Image,
-    Input, InputNumber,
-    Layout,
-    Modal,
-    Space,
-    Tag,
-    Typography
-} from "@douyinfe/semi-ui";
-import {getQuotaPerUnit, renderQuota, renderQuotaWithPrompt, stringToColor} from "../helpers/render";
-import EditToken from "../pages/Token/EditToken";
-import EditUser from "../pages/User/EditUser";
-import passwordResetConfirm from "./PasswordResetConfirm";
+  Avatar,
+  Banner,
+  Button,
+  Card,
+  Descriptions,
+  Image,
+  Input,
+  InputNumber,
+  Layout,
+  Modal,
+  Space,
+  Tag,
+  Typography
+} from '@douyinfe/semi-ui';
+import { getQuotaPerUnit, renderQuota, renderQuotaWithPrompt, stringToColor } from '../helpers/render';
 import TelegramLoginButton from 'react-telegram-login';
 
 const PersonalSetting = () => {
-    const [userState, userDispatch] = useContext(UserContext);
-    let navigate = useNavigate();
+  const [userState, userDispatch] = useContext(UserContext);
+  let navigate = useNavigate();
 
-    const [inputs, setInputs] = useState({
-        wechat_verification_code: '',
-        email_verification_code: '',
-        email: '',
-        self_account_deletion_confirmation: '',
-        set_new_password: '',
-        set_new_password_confirmation: '',
-    });
-    const [status, setStatus] = useState({});
-    const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
-    const [showWeChatBindModal, setShowWeChatBindModal] = useState(false);
-    const [showEmailBindModal, setShowEmailBindModal] = useState(false);
-    const [showAccountDeleteModal, setShowAccountDeleteModal] = useState(false);
-    const [turnstileEnabled, setTurnstileEnabled] = useState(false);
-    const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
-    const [turnstileToken, setTurnstileToken] = useState('');
-    const [loading, setLoading] = useState(false);
-    const [disableButton, setDisableButton] = useState(false);
-    const [countdown, setCountdown] = useState(30);
-    const [affLink, setAffLink] = useState("");
-    const [systemToken, setSystemToken] = useState("");
-    const [models, setModels] = useState([]);
-    const [openTransfer, setOpenTransfer] = useState(false);
-    const [transferAmount, setTransferAmount] = useState(0);
+  const [inputs, setInputs] = useState({
+    wechat_verification_code: '',
+    email_verification_code: '',
+    email: '',
+    self_account_deletion_confirmation: '',
+    set_new_password: '',
+    set_new_password_confirmation: ''
+  });
+  const [status, setStatus] = useState({});
+  const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
+  const [showWeChatBindModal, setShowWeChatBindModal] = useState(false);
+  const [showEmailBindModal, setShowEmailBindModal] = useState(false);
+  const [showAccountDeleteModal, setShowAccountDeleteModal] = useState(false);
+  const [turnstileEnabled, setTurnstileEnabled] = useState(false);
+  const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
+  const [turnstileToken, setTurnstileToken] = useState('');
+  const [loading, setLoading] = useState(false);
+  const [disableButton, setDisableButton] = useState(false);
+  const [countdown, setCountdown] = useState(30);
+  const [affLink, setAffLink] = useState('');
+  const [systemToken, setSystemToken] = useState('');
+  const [models, setModels] = useState([]);
+  const [openTransfer, setOpenTransfer] = useState(false);
+  const [transferAmount, setTransferAmount] = useState(0);
 
-    useEffect(() => {
-        // let user = localStorage.getItem('user');
-        // if (user) {
-        //   userDispatch({ type: 'login', payload: user });
-        // }
-        // console.log(localStorage.getItem('user'))
+  useEffect(() => {
+    // let user = localStorage.getItem('user');
+    // if (user) {
+    //   userDispatch({ type: 'login', payload: user });
+    // }
+    // console.log(localStorage.getItem('user'))
 
-        let status = localStorage.getItem('status');
-        if (status) {
-            status = JSON.parse(status);
-            setStatus(status);
-            if (status.turnstile_check) {
-                setTurnstileEnabled(true);
-                setTurnstileSiteKey(status.turnstile_site_key);
-            }
-        }
-        getUserData().then(
-            (res) => {
-                console.log(userState)
-            }
-        );
-        loadModels().then();
-        getAffLink().then();
-        setTransferAmount(getQuotaPerUnit())
-    }, []);
-
-    useEffect(() => {
-        let countdownInterval = null;
-        if (disableButton && countdown > 0) {
-            countdownInterval = setInterval(() => {
-                setCountdown(countdown - 1);
-            }, 1000);
-        } else if (countdown === 0) {
-            setDisableButton(false);
-            setCountdown(30);
-        }
-        return () => clearInterval(countdownInterval); // Clean up on unmount
-    }, [disableButton, countdown]);
+    let status = localStorage.getItem('status');
+    if (status) {
+      status = JSON.parse(status);
+      setStatus(status);
+      if (status.turnstile_check) {
+        setTurnstileEnabled(true);
+        setTurnstileSiteKey(status.turnstile_site_key);
+      }
+    }
+    getUserData().then(
+      (res) => {
+        console.log(userState);
+      }
+    );
+    loadModels().then();
+    getAffLink().then();
+    setTransferAmount(getQuotaPerUnit());
+  }, []);
 
-    const handleInputChange = (name, value) => {
-        setInputs((inputs) => ({...inputs, [name]: value}));
-    };
+  useEffect(() => {
+    let countdownInterval = null;
+    if (disableButton && countdown > 0) {
+      countdownInterval = setInterval(() => {
+        setCountdown(countdown - 1);
+      }, 1000);
+    } else if (countdown === 0) {
+      setDisableButton(false);
+      setCountdown(30);
+    }
+    return () => clearInterval(countdownInterval); // Clean up on unmount
+  }, [disableButton, countdown]);
 
-    const generateAccessToken = async () => {
-        const res = await API.get('/api/user/token');
-        const {success, message, data} = res.data;
-        if (success) {
-            setSystemToken(data);
-            await copy(data);
-            showSuccess(`令牌已重置并已复制到剪贴板`);
-        } else {
-            showError(message);
-        }
-    };
+  const handleInputChange = (name, value) => {
+    setInputs((inputs) => ({ ...inputs, [name]: value }));
+  };
 
-    const getAffLink = async () => {
-        const res = await API.get('/api/user/aff');
-        const {success, message, data} = res.data;
-        if (success) {
-            let link = `${window.location.origin}/register?aff=${data}`;
-            setAffLink(link);
-        } else {
-            showError(message);
-        }
-    };
+  const generateAccessToken = async () => {
+    const res = await API.get('/api/user/token');
+    const { success, message, data } = res.data;
+    if (success) {
+      setSystemToken(data);
+      await copy(data);
+      showSuccess(`令牌已重置并已复制到剪贴板`);
+    } else {
+      showError(message);
+    }
+  };
 
-    const getUserData = async () => {
-        let res = await API.get(`/api/user/self`);
-        const {success, message, data} = res.data;
-        if (success) {
-            userDispatch({type: 'login', payload: data});
-        } else {
-            showError(message);
-        }
+  const getAffLink = async () => {
+    const res = await API.get('/api/user/aff');
+    const { success, message, data } = res.data;
+    if (success) {
+      let link = `${window.location.origin}/register?aff=${data}`;
+      setAffLink(link);
+    } else {
+      showError(message);
     }
+  };
 
-    const loadModels = async () => {
-        let res = await API.get(`/api/user/models`);
-        const {success, message, data} = res.data;
-        if (success) {
-            setModels(data);
-            console.log(data)
-        } else {
-            showError(message);
-        }
+  const getUserData = async () => {
+    let res = await API.get(`/api/user/self`);
+    const { success, message, data } = res.data;
+    if (success) {
+      userDispatch({ type: 'login', payload: data });
+    } else {
+      showError(message);
     }
+  };
 
-    const handleAffLinkClick = async (e) => {
-        e.target.select();
-        await copy(e.target.value);
-        showSuccess(`邀请链接已复制到剪切板`);
-    };
+  const loadModels = async () => {
+    let res = await API.get(`/api/user/models`);
+    const { success, message, data } = res.data;
+    if (success) {
+      setModels(data);
+      console.log(data);
+    } else {
+      showError(message);
+    }
+  };
 
-    const handleSystemTokenClick = async (e) => {
-        e.target.select();
-        await copy(e.target.value);
-        showSuccess(`系统令牌已复制到剪切板`);
-    };
+  const handleAffLinkClick = async (e) => {
+    e.target.select();
+    await copy(e.target.value);
+    showSuccess(`邀请链接已复制到剪切板`);
+  };
 
-    const deleteAccount = async () => {
-        if (inputs.self_account_deletion_confirmation !== userState.user.username) {
-            showError('请输入你的账户名以确认删除!');
-            return;
-        }
+  const handleSystemTokenClick = async (e) => {
+    e.target.select();
+    await copy(e.target.value);
+    showSuccess(`系统令牌已复制到剪切板`);
+  };
 
-        const res = await API.delete('/api/user/self');
-        const {success, message} = res.data;
+  const deleteAccount = async () => {
+    if (inputs.self_account_deletion_confirmation !== userState.user.username) {
+      showError('请输入你的账户名以确认删除!');
+      return;
+    }
 
-        if (success) {
-            showSuccess('账户已删除!');
-            await API.get('/api/user/logout');
-            userDispatch({type: 'logout'});
-            localStorage.removeItem('user');
-            navigate('/login');
-        } else {
-            showError(message);
-        }
-    };
+    const res = await API.delete('/api/user/self');
+    const { success, message } = res.data;
 
-    const bindWeChat = async () => {
-        if (inputs.wechat_verification_code === '') return;
-        const res = await API.get(
-            `/api/oauth/wechat/bind?code=${inputs.wechat_verification_code}`
-        );
-        const {success, message} = res.data;
-        if (success) {
-            showSuccess('微信账户绑定成功!');
-            setShowWeChatBindModal(false);
-        } else {
-            showError(message);
-        }
-    };
+    if (success) {
+      showSuccess('账户已删除!');
+      await API.get('/api/user/logout');
+      userDispatch({ type: 'logout' });
+      localStorage.removeItem('user');
+      navigate('/login');
+    } else {
+      showError(message);
+    }
+  };
 
-    const changePassword = async () => {
-        if (inputs.set_new_password !== inputs.set_new_password_confirmation) {
-            showError('两次输入的密码不一致!');
-            return;
-        }
-        const res = await API.put(
-            `/api/user/self`,
-            {
-                password: inputs.set_new_password
-            }
-        );
-        const {success, message} = res.data;
-        if (success) {
-            showSuccess('密码修改成功!');
-            setShowWeChatBindModal(false);
-        } else {
-            showError(message);
-        }
-        setShowChangePasswordModal(false);
-    };
+  const bindWeChat = async () => {
+    if (inputs.wechat_verification_code === '') return;
+    const res = await API.get(
+      `/api/oauth/wechat/bind?code=${inputs.wechat_verification_code}`
+    );
+    const { success, message } = res.data;
+    if (success) {
+      showSuccess('微信账户绑定成功!');
+      setShowWeChatBindModal(false);
+    } else {
+      showError(message);
+    }
+  };
 
-    const transfer = async () => {
-        if (transferAmount < getQuotaPerUnit()) {
-            showError('划转金额最低为' + renderQuota(getQuotaPerUnit()));
-            return;
-        }
-        const res = await API.post(
-            `/api/user/aff_transfer`,
-            {
-                quota: transferAmount
-            }
-        );
-        const {success, message} = res.data;
-        if (success) {
-            showSuccess(message);
-            setOpenTransfer(false);
-            getUserData().then();
-        } else {
-            showError(message);
-        }
-    };
+  const changePassword = async () => {
+    if (inputs.set_new_password !== inputs.set_new_password_confirmation) {
+      showError('两次输入的密码不一致!');
+      return;
+    }
+    const res = await API.put(
+      `/api/user/self`,
+      {
+        password: inputs.set_new_password
+      }
+    );
+    const { success, message } = res.data;
+    if (success) {
+      showSuccess('密码修改成功!');
+      setShowWeChatBindModal(false);
+    } else {
+      showError(message);
+    }
+    setShowChangePasswordModal(false);
+  };
 
-    const sendVerificationCode = async () => {
-        if (inputs.email === '') {
-            showError('请输入邮箱!');
-            return;
-        }
-        setDisableButton(true);
-        if (turnstileEnabled && turnstileToken === '') {
-            showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
-            return;
-        }
-        setLoading(true);
-        const res = await API.get(
-            `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`
-        );
-        const {success, message} = res.data;
-        if (success) {
-            showSuccess('验证码发送成功,请检查邮箱!');
-        } else {
-            showError(message);
-        }
-        setLoading(false);
-    };
+  const transfer = async () => {
+    if (transferAmount < getQuotaPerUnit()) {
+      showError('划转金额最低为' + renderQuota(getQuotaPerUnit()));
+      return;
+    }
+    const res = await API.post(
+      `/api/user/aff_transfer`,
+      {
+        quota: transferAmount
+      }
+    );
+    const { success, message } = res.data;
+    if (success) {
+      showSuccess(message);
+      setOpenTransfer(false);
+      getUserData().then();
+    } else {
+      showError(message);
+    }
+  };
 
-    const bindEmail = async () => {
-        if (inputs.email_verification_code === '') {
-            showError('请输入邮箱验证码!');
-            return;
-        }
-        setLoading(true);
-        const res = await API.get(
-            `/api/oauth/email/bind?email=${inputs.email}&code=${inputs.email_verification_code}`
-        );
-        const {success, message} = res.data;
-        if (success) {
-            showSuccess('邮箱账户绑定成功!');
-            setShowEmailBindModal(false);
-            userState.user.email = inputs.email;
-        } else {
-            showError(message);
-        }
-        setLoading(false);
-    };
+  const sendVerificationCode = async () => {
+    if (inputs.email === '') {
+      showError('请输入邮箱!');
+      return;
+    }
+    setDisableButton(true);
+    if (turnstileEnabled && turnstileToken === '') {
+      showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
+      return;
+    }
+    setLoading(true);
+    const res = await API.get(
+      `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`
+    );
+    const { success, message } = res.data;
+    if (success) {
+      showSuccess('验证码发送成功,请检查邮箱!');
+    } else {
+      showError(message);
+    }
+    setLoading(false);
+  };
 
-    const getUsername = () => {
-        if (userState.user) {
-            return userState.user.username;
-        } else {
-            return 'null';
-        }
+  const bindEmail = async () => {
+    if (inputs.email_verification_code === '') {
+      showError('请输入邮箱验证码!');
+      return;
     }
+    setLoading(true);
+    const res = await API.get(
+      `/api/oauth/email/bind?email=${inputs.email}&code=${inputs.email_verification_code}`
+    );
+    const { success, message } = res.data;
+    if (success) {
+      showSuccess('邮箱账户绑定成功!');
+      setShowEmailBindModal(false);
+      userState.user.email = inputs.email;
+    } else {
+      showError(message);
+    }
+    setLoading(false);
+  };
 
-    const handleCancel = () => {
-        setOpenTransfer(false);
+  const getUsername = () => {
+    if (userState.user) {
+      return userState.user.username;
+    } else {
+      return 'null';
     }
+  };
 
-    const copyText = async (text) => {
-        if (await copy(text)) {
-            showSuccess('已复制:' + text);
-        } else {
-            // setSearchKeyword(text);
-            Modal.error({title: '无法复制到剪贴板,请手动复制', content: text});
-        }
+  const handleCancel = () => {
+    setOpenTransfer(false);
+  };
+
+  const copyText = async (text) => {
+    if (await copy(text)) {
+      showSuccess('已复制:' + text);
+    } else {
+      // setSearchKeyword(text);
+      Modal.error({ title: '无法复制到剪贴板,请手动复制', content: text });
     }
+  };
 
-    return (
-        <div>
-            <Layout>
-                <Layout.Content>
-                    <Modal
-                        title="请输入要划转的数量"
-                        visible={openTransfer}
-                        onOk={transfer}
-                        onCancel={handleCancel}
-                        maskClosable={false}
-                        size={'small'}
-                        centered={true}
-                    >
-                        <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>
-                        </div>
-                        <div style={{marginTop: 20}}>
-                            <Typography.Text>{`划转额度${renderQuotaWithPrompt(transferAmount)} 最低` + renderQuota(getQuotaPerUnit())}</Typography.Text>
-                            <div>
-                                <InputNumber min={0} style={{marginTop: 5}} value={transferAmount} onChange={(value)=>setTransferAmount(value)} disabled={false}></InputNumber>
-                            </div>
-                        </div>
-                    </Modal>
-                    <div style={{marginTop: 20}}>
-                        <Card
-                            title={
-                                <Card.Meta
-                                    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>}
-                                ></Card.Meta>
-                            }
-                            headerExtraContent={
-                                <>
-                                    <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>
-                            }
-                        >
-                            <Typography.Title heading={6}>可用模型</Typography.Title>
-                            <div style={{marginTop: 10}}>
-                                <Space wrap>
-                                    {models.map((model) => (
-                                        <Tag key={model} color="cyan" onClick={() => {
-                                            copyText(model)
-                                        }}>
-                                            {model}
-                                        </Tag>
-                                    ))}
-                                </Space>
-                            </div>
+  return (
+    <div>
+      <Layout>
+        <Layout.Content>
+          <Modal
+            title="请输入要划转的数量"
+            visible={openTransfer}
+            onOk={transfer}
+            onCancel={handleCancel}
+            maskClosable={false}
+            size={'small'}
+            centered={true}
+          >
+            <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>
+            </div>
+            <div style={{ marginTop: 20 }}>
+              <Typography.Text>{`划转额度${renderQuotaWithPrompt(transferAmount)} 最低` + renderQuota(getQuotaPerUnit())}</Typography.Text>
+              <div>
+                <InputNumber min={0} style={{ marginTop: 5 }} value={transferAmount}
+                             onChange={(value) => setTransferAmount(value)} disabled={false}></InputNumber>
+              </div>
+            </div>
+          </Modal>
+          <div style={{ marginTop: 20 }}>
+            <Card
+              title={
+                <Card.Meta
+                  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>}
+                ></Card.Meta>
+              }
+              headerExtraContent={
+                <>
+                  <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>
+              }
+            >
+              <Typography.Title heading={6}>可用模型</Typography.Title>
+              <div style={{ marginTop: 10 }}>
+                <Space wrap>
+                  {models.map((model) => (
+                    <Tag key={model} color="cyan" onClick={() => {
+                      copyText(model);
+                    }}>
+                      {model}
+                    </Tag>
+                  ))}
+                </Space>
+              </div>
 
-                        </Card>
-                        <Card
-                            footer={
-                                <div>
-                                    <Typography.Text>邀请链接</Typography.Text>
-                                    <Input
-                                        style={{marginTop: 10}}
-                                        value={affLink}
-                                        onClick={handleAffLinkClick}
-                                        readOnly
-                                    />
-                                </div>
-                            }
-                        >
-                            <Typography.Title heading={6}>邀请信息</Typography.Title>
-                            <div style={{marginTop: 10}}>
-                                <Descriptions row>
-                                    <Descriptions.Item itemKey="待使用收益">
-                                        <span style={{color: 'rgba(var(--semi-red-5), 1)'}}>
+            </Card>
+            <Card
+              footer={
+                <div>
+                  <Typography.Text>邀请链接</Typography.Text>
+                  <Input
+                    style={{ marginTop: 10 }}
+                    value={affLink}
+                    onClick={handleAffLinkClick}
+                    readOnly
+                  />
+                </div>
+              }
+            >
+              <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)
+                                              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>
-                            </div>
-                        </Card>
-                        <Card>
-                            <Typography.Title heading={6}>个人信息</Typography.Title>
-                            <div style={{marginTop: 20}}>
-                                <Typography.Text strong>邮箱</Typography.Text>
-                                <div style={{display: 'flex', justifyContent: 'space-between'}}>
-                                    <div>
-                                        <Input
-                                            value={userState.user && userState.user.email !== ''?userState.user.email:'未绑定'}
-                                            readonly={true}
-                                        ></Input>
-                                    </div>
-                                    <div>
-                                        <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>
-                                        <Input
-                                            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>
-                                    </div>
-                                </div>
-                            </div>
-                            <div style={{marginTop: 10}}>
-                                <Typography.Text strong>GitHub</Typography.Text>
-                                <div style={{display: 'flex', justifyContent: 'space-between'}}>
-                                    <div>
-                                        <Input
-                                            value={userState.user && userState.user.github_id !== ''?userState.user.github_id:'未绑定'}
-                                            readonly={true}
-                                        ></Input>
-                                    </div>
-                                    <div>
-                                        <Button
-                                            onClick={() => {onGitHubOAuthClicked(status.github_client_id)}}
-                                            disabled={(userState.user && userState.user.github_id !== '') || !status.github_oauth}
-                                        >
-                                            {
-                                                status.github_oauth?'绑定':'未启用'
-                                            }
-                                        </Button>
-                                    </div>
-                                </div>
-                            </div>
+                    <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>
+              </div>
+            </Card>
+            <Card>
+              <Typography.Title heading={6}>个人信息</Typography.Title>
+              <div style={{ marginTop: 20 }}>
+                <Typography.Text strong>邮箱</Typography.Text>
+                <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+                  <div>
+                    <Input
+                      value={userState.user && userState.user.email !== '' ? userState.user.email : '未绑定'}
+                      readonly={true}
+                    ></Input>
+                  </div>
+                  <div>
+                    <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>
+                    <Input
+                      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>
+                  </div>
+                </div>
+              </div>
+              <div style={{ marginTop: 10 }}>
+                <Typography.Text strong>GitHub</Typography.Text>
+                <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+                  <div>
+                    <Input
+                      value={userState.user && userState.user.github_id !== '' ? userState.user.github_id : '未绑定'}
+                      readonly={true}
+                    ></Input>
+                  </div>
+                  <div>
+                    <Button
+                      onClick={() => {
+                        onGitHubOAuthClicked(status.github_client_id);
+                      }}
+                      disabled={(userState.user && userState.user.github_id !== '') || !status.github_oauth}
+                    >
+                      {
+                        status.github_oauth ? '绑定' : '未启用'
+                      }
+                    </Button>
+                  </div>
+                </div>
+              </div>
 
-                            <div style={{marginTop: 10}}>
-                                <Typography.Text strong>Telegram</Typography.Text>
-                                <div style={{display: 'flex', justifyContent: 'space-between'}}>
-                                    <div>
-                                        <Input
-                                            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>
-                                        }
-                                    </div>
-                                </div>
-                            </div>
+              <div style={{ marginTop: 10 }}>
+                <Typography.Text strong>Telegram</Typography.Text>
+                <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+                  <div>
+                    <Input
+                      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>
+                    }
+                  </div>
+                </div>
+              </div>
 
-                            <div style={{marginTop: 10}}>
-                                <Space>
-                                    <Button onClick={generateAccessToken}>生成系统访问令牌</Button>
-                                    <Button onClick={() => {
-                                        setShowChangePasswordModal(true);
-                                    }}>修改密码</Button>
-                                    <Button type={'danger'} onClick={() => {
-                                        setShowAccountDeleteModal(true);
-                                    }}>删除个人账户</Button>
-                                </Space>
+              <div style={{ marginTop: 10 }}>
+                <Space>
+                  <Button onClick={generateAccessToken}>生成系统访问令牌</Button>
+                  <Button onClick={() => {
+                    setShowChangePasswordModal(true);
+                  }}>修改密码</Button>
+                  <Button type={'danger'} onClick={() => {
+                    setShowAccountDeleteModal(true);
+                  }}>删除个人账户</Button>
+                </Space>
 
-                                {systemToken && (
-                                    <Input
-                                        readOnly
-                                        value={systemToken}
-                                        onClick={handleSystemTokenClick}
-                                        style={{marginTop: '10px'}}
-                                    />
-                                )}
-                                {
-                                    status.wechat_login && (
-                                        <Button
-                                            onClick={() => {
-                                                setShowWeChatBindModal(true);
-                                            }}
-                                        >
-                                            绑定微信账号
-                                        </Button>
-                                    )
-                                }
-                                <Modal
-                                    onCancel={() => setShowWeChatBindModal(false)}
-                                    // onOpen={() => setShowWeChatBindModal(true)}
-                                    visible={showWeChatBindModal}
-                                    size={'mini'}
-                                >
-                                    <Image src={status.wechat_qrcode}/>
-                                    <div style={{textAlign: 'center'}}>
-                                        <p>
-                                            微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)
-                                        </p>
-                                    </div>
-                                    <Input
-                                        placeholder='验证码'
-                                        name='wechat_verification_code'
-                                        value={inputs.wechat_verification_code}
-                                        onChange={(v)=>handleInputChange('wechat_verification_code', v)}
-                                    />
-                                    <Button color='' fluid size='large' onClick={bindWeChat}>
-                                        绑定
-                                    </Button>
-                                </Modal>
-                            </div>
-                        </Card>
-                        <Modal
-                            onCancel={() => setShowEmailBindModal(false)}
-                            // onOpen={() => setShowEmailBindModal(true)}
-                            onOk={bindEmail}
-                            visible={showEmailBindModal}
-                            size={'small'}
-                            centered={true}
-                            maskClosable={false}
-                        >
-                            <Typography.Title heading={6}>绑定邮箱地址</Typography.Title>
-                            <div style={{marginTop: 20, display: 'flex', justifyContent: 'space-between'}}>
-                                <Input
-                                    fluid
-                                    placeholder='输入邮箱地址'
-                                    onChange={(value)=>handleInputChange('email', value)}
-                                    name='email'
-                                    type='email'
-                                />
-                                <Button onClick={sendVerificationCode}
-                                        disabled={disableButton || loading}>
-                                    {disableButton ? `重新发送(${countdown})` : '获取验证码'}
-                                </Button>
-                            </div>
-                            <div style={{marginTop: 10}}>
-                                <Input
-                                    fluid
-                                    placeholder='验证码'
-                                    name='email_verification_code'
-                                    value={inputs.email_verification_code}
-                                    onChange={(value)=>handleInputChange('email_verification_code', value)}
-                                />
-                            </div>
-                            {turnstileEnabled ? (
-                                <Turnstile
-                                    sitekey={turnstileSiteKey}
-                                    onVerify={(token) => {
-                                        setTurnstileToken(token);
-                                    }}
-                                />
-                            ) : (
-                                <></>
-                            )}
-                        </Modal>
-                        <Modal
-                            onCancel={() => setShowAccountDeleteModal(false)}
-                            visible={showAccountDeleteModal}
-                            size={'small'}
-                            centered={true}
-                            onOk={deleteAccount}
-                        >
-                            <div style={{marginTop: 20}}>
-                                <Banner
-                                    type="danger"
-                                    description="您正在删除自己的帐户,将清空所有数据且不可恢复"
-                                    closeIcon={null}
-                                />
-                            </div>
-                            <div style={{marginTop: 20}}>
-                                <Input
-                                    placeholder={`输入你的账户名 ${userState?.user?.username} 以确认删除`}
-                                    name='self_account_deletion_confirmation'
-                                    value={inputs.self_account_deletion_confirmation}
-                                    onChange={(value)=>handleInputChange('self_account_deletion_confirmation', value)}
-                                />
-                                {turnstileEnabled ? (
-                                    <Turnstile
-                                        sitekey={turnstileSiteKey}
-                                        onVerify={(token) => {
-                                            setTurnstileToken(token);
-                                        }}
-                                    />
-                                ) : (
-                                    <></>
-                                )}
-                            </div>
-                        </Modal>
-                        <Modal
-                            onCancel={() => setShowChangePasswordModal(false)}
-                            visible={showChangePasswordModal}
-                            size={'small'}
-                            centered={true}
-                            onOk={changePassword}
-                        >
-                            <div style={{marginTop: 20}}>
-                                <Input
-                                    name='set_new_password'
-                                    placeholder='新密码'
-                                    value={inputs.set_new_password}
-                                    onChange={(value)=>handleInputChange('set_new_password', value)}
-                                />
-                                <Input
-                                    style={{marginTop: 20}}
-                                    name='set_new_password_confirmation'
-                                    placeholder='确认新密码'
-                                    value={inputs.set_new_password_confirmation}
-                                    onChange={(value)=>handleInputChange('set_new_password_confirmation', value)}
-                                />
-                                {turnstileEnabled ? (
-                                    <Turnstile
-                                        sitekey={turnstileSiteKey}
-                                        onVerify={(token) => {
-                                            setTurnstileToken(token);
-                                        }}
-                                    />
-                                ) : (
-                                    <></>
-                                )}
-                            </div>
-                        </Modal>
-                    </div>
+                {systemToken && (
+                  <Input
+                    readOnly
+                    value={systemToken}
+                    onClick={handleSystemTokenClick}
+                    style={{ marginTop: '10px' }}
+                  />
+                )}
+                {
+                  status.wechat_login && (
+                    <Button
+                      onClick={() => {
+                        setShowWeChatBindModal(true);
+                      }}
+                    >
+                      绑定微信账号
+                    </Button>
+                  )
+                }
+                <Modal
+                  onCancel={() => setShowWeChatBindModal(false)}
+                  // onOpen={() => setShowWeChatBindModal(true)}
+                  visible={showWeChatBindModal}
+                  size={'mini'}
+                >
+                  <Image src={status.wechat_qrcode} />
+                  <div style={{ textAlign: 'center' }}>
+                    <p>
+                      微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)
+                    </p>
+                  </div>
+                  <Input
+                    placeholder="验证码"
+                    name="wechat_verification_code"
+                    value={inputs.wechat_verification_code}
+                    onChange={(v) => handleInputChange('wechat_verification_code', v)}
+                  />
+                  <Button color="" fluid size="large" onClick={bindWeChat}>
+                    绑定
+                  </Button>
+                </Modal>
+              </div>
+            </Card>
+            <Modal
+              onCancel={() => setShowEmailBindModal(false)}
+              // onOpen={() => setShowEmailBindModal(true)}
+              onOk={bindEmail}
+              visible={showEmailBindModal}
+              size={'small'}
+              centered={true}
+              maskClosable={false}
+            >
+              <Typography.Title heading={6}>绑定邮箱地址</Typography.Title>
+              <div style={{ marginTop: 20, display: 'flex', justifyContent: 'space-between' }}>
+                <Input
+                  fluid
+                  placeholder="输入邮箱地址"
+                  onChange={(value) => handleInputChange('email', value)}
+                  name="email"
+                  type="email"
+                />
+                <Button onClick={sendVerificationCode}
+                        disabled={disableButton || loading}>
+                  {disableButton ? `重新发送(${countdown})` : '获取验证码'}
+                </Button>
+              </div>
+              <div style={{ marginTop: 10 }}>
+                <Input
+                  fluid
+                  placeholder="验证码"
+                  name="email_verification_code"
+                  value={inputs.email_verification_code}
+                  onChange={(value) => handleInputChange('email_verification_code', value)}
+                />
+              </div>
+              {turnstileEnabled ? (
+                <Turnstile
+                  sitekey={turnstileSiteKey}
+                  onVerify={(token) => {
+                    setTurnstileToken(token);
+                  }}
+                />
+              ) : (
+                <></>
+              )}
+            </Modal>
+            <Modal
+              onCancel={() => setShowAccountDeleteModal(false)}
+              visible={showAccountDeleteModal}
+              size={'small'}
+              centered={true}
+              onOk={deleteAccount}
+            >
+              <div style={{ marginTop: 20 }}>
+                <Banner
+                  type="danger"
+                  description="您正在删除自己的帐户,将清空所有数据且不可恢复"
+                  closeIcon={null}
+                />
+              </div>
+              <div style={{ marginTop: 20 }}>
+                <Input
+                  placeholder={`输入你的账户名 ${userState?.user?.username} 以确认删除`}
+                  name="self_account_deletion_confirmation"
+                  value={inputs.self_account_deletion_confirmation}
+                  onChange={(value) => handleInputChange('self_account_deletion_confirmation', value)}
+                />
+                {turnstileEnabled ? (
+                  <Turnstile
+                    sitekey={turnstileSiteKey}
+                    onVerify={(token) => {
+                      setTurnstileToken(token);
+                    }}
+                  />
+                ) : (
+                  <></>
+                )}
+              </div>
+            </Modal>
+            <Modal
+              onCancel={() => setShowChangePasswordModal(false)}
+              visible={showChangePasswordModal}
+              size={'small'}
+              centered={true}
+              onOk={changePassword}
+            >
+              <div style={{ marginTop: 20 }}>
+                <Input
+                  name="set_new_password"
+                  placeholder="新密码"
+                  value={inputs.set_new_password}
+                  onChange={(value) => handleInputChange('set_new_password', value)}
+                />
+                <Input
+                  style={{ marginTop: 20 }}
+                  name="set_new_password_confirmation"
+                  placeholder="确认新密码"
+                  value={inputs.set_new_password_confirmation}
+                  onChange={(value) => handleInputChange('set_new_password_confirmation', value)}
+                />
+                {turnstileEnabled ? (
+                  <Turnstile
+                    sitekey={turnstileSiteKey}
+                    onVerify={(token) => {
+                      setTurnstileToken(token);
+                    }}
+                  />
+                ) : (
+                  <></>
+                )}
+              </div>
+            </Modal>
+          </div>
 
-                </Layout.Content>
-            </Layout>
-        </div>
-    );
+        </Layout.Content>
+      </Layout>
+    </div>
+  );
 };
 
 export default PersonalSetting;

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

@@ -5,7 +5,7 @@ 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;
 }

+ 370 - 370
web/src/components/RedemptionsTable.js

@@ -1,406 +1,406 @@
-import React, {useEffect, useState} from 'react';
-import {API, copy, showError, showInfo, showSuccess, showWarning, timestamp2string} from '../helpers';
+import React, { useEffect, useState } from 'react';
+import { API, copy, showError, showSuccess, timestamp2string } from '../helpers';
 
-import {ITEMS_PER_PAGE} from '../constants';
-import {renderQuota} from '../helpers/render';
-import {Button, Modal, Popconfirm, Popover, Table, Tag, Form} from "@douyinfe/semi-ui";
-import EditRedemption from "../pages/Redemption/EditRedemption";
+import { ITEMS_PER_PAGE } from '../constants';
+import { renderQuota } from '../helpers/render';
+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>;
-        case 2:
-            return <Tag color='red' size='large'> 已禁用 </Tag>;
-        case 3:
-            return <Tag color='grey' size='large'> 已使用 </Tag>;
-        default:
-            return <Tag color='black' size='large'> 未知状态 </Tag>;
-    }
+  switch (status) {
+    case 1:
+      return <Tag color="green" size="large">未使用</Tag>;
+    case 2:
+      return <Tag color="red" size="large"> 已禁用 </Tag>;
+    case 3:
+      return <Tag color="grey" size="large"> 已使用 </Tag>;
+    default:
+      return <Tag color="black" size="large"> 未知状态 </Tag>;
+  }
 }
 
 const RedemptionsTable = () => {
-    const columns = [
-        {
-            title: 'ID',
-            dataIndex: 'id',
-        },
-        {
-            title: '名称',
-            dataIndex: 'name',
-        },
-        {
-            title: '状态',
-            dataIndex: 'status',
-            key: 'status',
-            render: (text, record, index) => {
-                return (
-                    <div>
-                        {renderStatus(text)}
-                    </div>
-                );
-            },
-        },
-        {
-            title: '额度',
-            dataIndex: 'quota',
-            render: (text, record, index) => {
-                return (
-                    <div>
-                        {renderQuota(parseInt(text))}
-                    </div>
-                );
-            },
-        },
-        {
-            title: '创建时间',
-            dataIndex: 'created_time',
-            render: (text, record, index) => {
-                return (
-                    <div>
-                        {renderTimestamp(text)}
-                    </div>
-                );
-            },
-        },
-        {
-            title: '兑换人ID',
-            dataIndex: 'used_user_id',
-            render: (text, record, index) => {
-                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>
-                    <Button theme='light' type='secondary' style={{marginRight: 1}}
-                            onClick={async (text) => {
-                                await copyText(record.key)
-                            }}
-                    >复制</Button>
-                    <Popconfirm
-                        title="确定是否要删除此兑换码?"
-                        content="此修改将不可逆"
-                        okType={'danger'}
-                        position={'left'}
-                        onConfirm={() => {
-                            manageRedemption(record.id, 'delete', record).then(
-                                () => {
-                                    removeRecord(record.key);
-                                }
-                            )
-                        }}
-                    >
-                        <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={
-                        () => {
-                            setEditingRedemption(record);
-                            setShowEdit(true);
-                        }
-                    } disabled={record.status !== 1}>编辑</Button>
-                </div>
-            ),
-        },
-    ];
+  const columns = [
+    {
+      title: 'ID',
+      dataIndex: 'id'
+    },
+    {
+      title: '名称',
+      dataIndex: 'name'
+    },
+    {
+      title: '状态',
+      dataIndex: 'status',
+      key: 'status',
+      render: (text, record, index) => {
+        return (
+          <div>
+            {renderStatus(text)}
+          </div>
+        );
+      }
+    },
+    {
+      title: '额度',
+      dataIndex: 'quota',
+      render: (text, record, index) => {
+        return (
+          <div>
+            {renderQuota(parseInt(text))}
+          </div>
+        );
+      }
+    },
+    {
+      title: '创建时间',
+      dataIndex: 'created_time',
+      render: (text, record, index) => {
+        return (
+          <div>
+            {renderTimestamp(text)}
+          </div>
+        );
+      }
+    },
+    {
+      title: '兑换人ID',
+      dataIndex: 'used_user_id',
+      render: (text, record, index) => {
+        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>
+          <Button theme="light" type="secondary" style={{ marginRight: 1 }}
+                  onClick={async (text) => {
+                    await copyText(record.key);
+                  }}
+          >复制</Button>
+          <Popconfirm
+            title="确定是否要删除此兑换码?"
+            content="此修改将不可逆"
+            okType={'danger'}
+            position={'left'}
+            onConfirm={() => {
+              manageRedemption(record.id, 'delete', record).then(
+                () => {
+                  removeRecord(record.key);
+                }
+              );
+            }}
+          >
+            <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={
+            () => {
+              setEditingRedemption(record);
+              setShowEdit(true);
+            }
+          } disabled={record.status !== 1}>编辑</Button>
+        </div>
+      )
+    }
+  ];
 
-    const [redemptions, setRedemptions] = useState([]);
-    const [loading, setLoading] = useState(true);
-    const [activePage, setActivePage] = useState(1);
-    const [searchKeyword, setSearchKeyword] = useState('');
-    const [searching, setSearching] = useState(false);
-    const [tokenCount, setTokenCount] = useState(ITEMS_PER_PAGE);
-    const [selectedKeys, setSelectedKeys] = useState([]);
-    const [editingRedemption, setEditingRedemption] = useState({
-        id: undefined,
-    });
-    const [showEdit, setShowEdit] = useState(false);
+  const [redemptions, setRedemptions] = useState([]);
+  const [loading, setLoading] = useState(true);
+  const [activePage, setActivePage] = useState(1);
+  const [searchKeyword, setSearchKeyword] = useState('');
+  const [searching, setSearching] = useState(false);
+  const [tokenCount, setTokenCount] = useState(ITEMS_PER_PAGE);
+  const [selectedKeys, setSelectedKeys] = useState([]);
+  const [editingRedemption, setEditingRedemption] = useState({
+    id: undefined
+  });
+  const [showEdit, setShowEdit] = useState(false);
 
-    const closeEdit = () => {
-        setShowEdit(false);
-    }
+  const closeEdit = () => {
+    setShowEdit(false);
+  };
 
-    // const setCount = (data) => {
-    //     if (data.length >= (activePage) * ITEMS_PER_PAGE) {
-    //         setTokenCount(data.length + 1);
-    //     } else {
-    //         setTokenCount(data.length);
-    //     }
-    // }
+  // const setCount = (data) => {
+  //     if (data.length >= (activePage) * ITEMS_PER_PAGE) {
+  //         setTokenCount(data.length + 1);
+  //     } else {
+  //         setTokenCount(data.length);
+  //     }
+  // }
 
-    const setRedemptionFormat = (redeptions) => {
-        // for (let i = 0; i < redeptions.length; i++) {
-        //     redeptions[i].key = '' + redeptions[i].id;
-        // }
-        // data.key = '' + data.id
-        setRedemptions(redeptions);
-        if (redeptions.length >= (activePage) * ITEMS_PER_PAGE) {
-            setTokenCount(redeptions.length + 1);
-        } else {
-            setTokenCount(redeptions.length);
-        }
+  const setRedemptionFormat = (redeptions) => {
+    // for (let i = 0; i < redeptions.length; i++) {
+    //     redeptions[i].key = '' + redeptions[i].id;
+    // }
+    // data.key = '' + data.id
+    setRedemptions(redeptions);
+    if (redeptions.length >= (activePage) * ITEMS_PER_PAGE) {
+      setTokenCount(redeptions.length + 1);
+    } else {
+      setTokenCount(redeptions.length);
     }
+  };
 
-    const loadRedemptions = async (startIdx) => {
-        const res = await API.get(`/api/redemption/?p=${startIdx}`);
-        const {success, message, data} = res.data;
-        if (success) {
-            if (startIdx === 0) {
-                setRedemptionFormat(data);
-            } else {
-                let newRedemptions = redemptions;
-                newRedemptions.push(...data);
-                setRedemptionFormat(newRedemptions);
-            }
-        } else {
-            showError(message);
-        }
-        setLoading(false);
-    };
+  const loadRedemptions = async (startIdx) => {
+    const res = await API.get(`/api/redemption/?p=${startIdx}`);
+    const { success, message, data } = res.data;
+    if (success) {
+      if (startIdx === 0) {
+        setRedemptionFormat(data);
+      } else {
+        let newRedemptions = redemptions;
+        newRedemptions.push(...data);
+        setRedemptionFormat(newRedemptions);
+      }
+    } else {
+      showError(message);
+    }
+    setLoading(false);
+  };
 
-    const removeRecord = key => {
-        let newDataSource = [...redemptions];
-        if (key != null) {
-            let idx = newDataSource.findIndex(data => data.key === key);
+  const removeRecord = key => {
+    let newDataSource = [...redemptions];
+    if (key != null) {
+      let idx = newDataSource.findIndex(data => data.key === key);
 
-            if (idx > -1) {
-                newDataSource.splice(idx, 1);
-                setRedemptions(newDataSource);
-            }
-        }
-    };
+      if (idx > -1) {
+        newDataSource.splice(idx, 1);
+        setRedemptions(newDataSource);
+      }
+    }
+  };
 
-    const copyText = async (text) => {
-        if (await copy(text)) {
-            showSuccess('已复制到剪贴板!');
-        } else {
-            // setSearchKeyword(text);
-            Modal.error({title: '无法复制到剪贴板,请手动复制', content: text});
-        }
+  const copyText = async (text) => {
+    if (await copy(text)) {
+      showSuccess('已复制到剪贴板!');
+    } else {
+      // setSearchKeyword(text);
+      Modal.error({ title: '无法复制到剪贴板,请手动复制', content: text });
     }
+  };
 
-    const onPaginationChange = (e, {activePage}) => {
-        (async () => {
-            if (activePage === Math.ceil(redemptions.length / ITEMS_PER_PAGE) + 1) {
-                // In this case we have to load more data and then append them.
-                await loadRedemptions(activePage - 1);
-            }
-            setActivePage(activePage);
-        })();
-    };
+  const onPaginationChange = (e, { activePage }) => {
+    (async () => {
+      if (activePage === Math.ceil(redemptions.length / ITEMS_PER_PAGE) + 1) {
+        // In this case we have to load more data and then append them.
+        await loadRedemptions(activePage - 1);
+      }
+      setActivePage(activePage);
+    })();
+  };
 
-    useEffect(() => {
-        loadRedemptions(0)
-            .then()
-            .catch((reason) => {
-                showError(reason);
-            });
-    }, []);
+  useEffect(() => {
+    loadRedemptions(0)
+      .then()
+      .catch((reason) => {
+        showError(reason);
+      });
+  }, []);
 
-    const refresh = async () => {
-        await loadRedemptions(activePage - 1);
-    };
+  const refresh = async () => {
+    await loadRedemptions(activePage - 1);
+  };
 
-    const manageRedemption = async (id, action, record) => {
-        let data = {id};
-        let res;
-        switch (action) {
-            case 'delete':
-                res = await API.delete(`/api/redemption/${id}/`);
-                break;
-            case 'enable':
-                data.status = 1;
-                res = await API.put('/api/redemption/?status_only=true', data);
-                break;
-            case 'disable':
-                data.status = 2;
-                res = await API.put('/api/redemption/?status_only=true', data);
-                break;
-        }
-        const {success, message} = res.data;
-        if (success) {
-            showSuccess('操作成功完成!');
-            let redemption = res.data.data;
-            let newRedemptions = [...redemptions];
-            // let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
-            if (action === 'delete') {
+  const manageRedemption = async (id, action, record) => {
+    let data = { id };
+    let res;
+    switch (action) {
+      case 'delete':
+        res = await API.delete(`/api/redemption/${id}/`);
+        break;
+      case 'enable':
+        data.status = 1;
+        res = await API.put('/api/redemption/?status_only=true', data);
+        break;
+      case 'disable':
+        data.status = 2;
+        res = await API.put('/api/redemption/?status_only=true', data);
+        break;
+    }
+    const { success, message } = res.data;
+    if (success) {
+      showSuccess('操作成功完成!');
+      let redemption = res.data.data;
+      let newRedemptions = [...redemptions];
+      // let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
+      if (action === 'delete') {
 
-            } else {
-                record.status = redemption.status;
-            }
-            setRedemptions(newRedemptions);
-        } else {
-            showError(message);
-        }
-    };
+      } else {
+        record.status = redemption.status;
+      }
+      setRedemptions(newRedemptions);
+    } else {
+      showError(message);
+    }
+  };
 
-    const searchRedemptions = async () => {
-        if (searchKeyword === '') {
-            // if keyword is blank, load files instead.
-            await loadRedemptions(0);
-            setActivePage(1);
-            return;
-        }
-        setSearching(true);
-        const res = await API.get(`/api/redemption/search?keyword=${searchKeyword}`);
-        const {success, message, data} = res.data;
-        if (success) {
-            setRedemptions(data);
-            setActivePage(1);
-        } else {
-            showError(message);
-        }
-        setSearching(false);
-    };
+  const searchRedemptions = async () => {
+    if (searchKeyword === '') {
+      // if keyword is blank, load files instead.
+      await loadRedemptions(0);
+      setActivePage(1);
+      return;
+    }
+    setSearching(true);
+    const res = await API.get(`/api/redemption/search?keyword=${searchKeyword}`);
+    const { success, message, data } = res.data;
+    if (success) {
+      setRedemptions(data);
+      setActivePage(1);
+    } else {
+      showError(message);
+    }
+    setSearching(false);
+  };
 
-    const handleKeywordChange = async (value) => {
-        setSearchKeyword(value.trim());
-    };
+  const handleKeywordChange = async (value) => {
+    setSearchKeyword(value.trim());
+  };
 
-    const sortRedemption = (key) => {
-        if (redemptions.length === 0) return;
-        setLoading(true);
-        let sortedRedemptions = [...redemptions];
-        sortedRedemptions.sort((a, b) => {
-            return ('' + a[key]).localeCompare(b[key]);
-        });
-        if (sortedRedemptions[0].id === redemptions[0].id) {
-            sortedRedemptions.reverse();
-        }
-        setRedemptions(sortedRedemptions);
-        setLoading(false);
-    };
+  const sortRedemption = (key) => {
+    if (redemptions.length === 0) return;
+    setLoading(true);
+    let sortedRedemptions = [...redemptions];
+    sortedRedemptions.sort((a, b) => {
+      return ('' + a[key]).localeCompare(b[key]);
+    });
+    if (sortedRedemptions[0].id === redemptions[0].id) {
+      sortedRedemptions.reverse();
+    }
+    setRedemptions(sortedRedemptions);
+    setLoading(false);
+  };
 
-    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 => {
-            });
-        }
-    };
+  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 => {
+      });
+    }
+  };
 
-    let pageData = redemptions.slice((activePage - 1) * ITEMS_PER_PAGE, activePage * ITEMS_PER_PAGE);
-    const rowSelection = {
-        onSelect: (record, selected) => {
-        },
-        onSelectAll: (selected, selectedRows) => {
-        },
-        onChange: (selectedRowKeys, selectedRows) => {
-            setSelectedKeys(selectedRows);
-        },
-    };
+  let pageData = redemptions.slice((activePage - 1) * ITEMS_PER_PAGE, activePage * ITEMS_PER_PAGE);
+  const rowSelection = {
+    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)',
-                },
-            };
-        } else {
-            return {};
+  const handleRow = (record, index) => {
+    if (record.status !== 1) {
+      return {
+        style: {
+          background: 'var(--semi-color-disabled-border)'
         }
-    };
+      };
+    } else {
+      return {};
+    }
+  };
 
-    return (
-        <>
-            <EditRedemption refresh={refresh} editingRedemption={editingRedemption} visiable={showEdit}
-                            handleClose={closeEdit}></EditRedemption>
-            <Form onSubmit={searchRedemptions}>
-                <Form.Input
-                    label='搜索关键字'
-                    field='keyword'
-                    icon='search'
-                    iconPosition='left'
-                    placeholder='关键字(id或者名称)'
-                    value={searchKeyword}
-                    loading={searching}
-                    onChange={handleKeywordChange}
-                />
-            </Form>
+  return (
+    <>
+      <EditRedemption refresh={refresh} editingRedemption={editingRedemption} visiable={showEdit}
+                      handleClose={closeEdit}></EditRedemption>
+      <Form onSubmit={searchRedemptions}>
+        <Form.Input
+          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={
-                () => {
-                    setEditingRedemption({
-                        id: undefined,
-                    });
-                    setShowEdit(true);
-                }
-            }>添加兑换码</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 + "    " + selectedKeys[i].key + "\n";
-                    }
-                    await copyText(keys);
-                }
-            }>复制所选兑换码到剪贴板</Button>
-        </>
-    );
+      <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
+          });
+          setShowEdit(true);
+        }
+      }>添加兑换码</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 + '    ' + selectedKeys[i].key + '\n';
+          }
+          await copyText(keys);
+        }
+      }>复制所选兑换码到剪贴板</Button>
+    </>
+  );
 };
 
 export default RedemptionsTable;

+ 29 - 29
web/src/components/RegisterForm.js

@@ -98,49 +98,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 +149,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 +170,9 @@ const RegisterForm = () => {
               <></>
             )}
             <Button
-              color='green'
+              color="green"
               fluid
-              size='large'
+              size="large"
               onClick={handleSubmit}
               loading={loading}
             >
@@ -182,7 +182,7 @@ const RegisterForm = () => {
         </Form>
         <Message>
           已有账户?
-          <Link to='/login' className='btn btn-link'>
+          <Link to="/login" className="btn btn-link">
             点击登录
           </Link>
         </Message>

+ 194 - 194
web/src/components/SiderBar.js

@@ -1,213 +1,213 @@
-import React, { useContext, useEffect, useLayoutEffect, useMemo, useState } from 'react';
-import {Link, useNavigate} from 'react-router-dom';
-import {UserContext} from '../context/User';
+import React, { useContext, useEffect, useMemo, useState } from 'react';
+import { Link, useNavigate } from 'react-router-dom';
+import { UserContext } from '../context/User';
 import { StatusContext } from '../context/Status';
 
-import { API, getLogo, getSystemName, isAdmin, isMobile, showError, showSuccess } from '../helpers';
+import { API, getLogo, getSystemName, isAdmin, isMobile, showError } from '../helpers';
 import '../index.css';
 
 import {
-    IconCalendarClock,
-    IconHistogram,
-    IconGift,
-    IconKey,
-    IconUser,
-    IconLayers,
-    IconSetting,
-    IconCreditCard,
-    IconComment,
-    IconHome,
-    IconImage
+  IconCalendarClock,
+  IconComment,
+  IconCreditCard,
+  IconGift,
+  IconHistogram,
+  IconHome,
+  IconImage,
+  IconKey,
+  IconLayers,
+  IconSetting,
+  IconUser
 } from '@douyinfe/semi-icons';
-import {Nav, Avatar, Dropdown, Layout} from '@douyinfe/semi-ui';
+import { Layout, Nav } from '@douyinfe/semi-ui';
 
 // HeaderBar Buttons
 
 const SiderBar = () => {
-    const [userState, userDispatch] = useContext(UserContext);
-    const [statusState, statusDispatch] = useContext(StatusContext);
-    const defaultIsCollapsed = isMobile() || localStorage.getItem('default_collapse_sidebar') === 'true';
+  const [userState, userDispatch] = useContext(UserContext);
+  const [statusState, statusDispatch] = useContext(StatusContext);
+  const defaultIsCollapsed = isMobile() || localStorage.getItem('default_collapse_sidebar') === 'true';
 
-    let navigate = useNavigate();
-    const [selectedKeys, setSelectedKeys] = useState(['home']);
-    const systemName = getSystemName();
-    const logo = getLogo();
-    const [isCollapsed, setIsCollapsed] = useState(defaultIsCollapsed);
+  let navigate = useNavigate();
+  const [selectedKeys, setSelectedKeys] = useState(['home']);
+  const systemName = getSystemName();
+  const logo = getLogo();
+  const [isCollapsed, setIsCollapsed] = useState(defaultIsCollapsed);
 
-    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');
-        const { success, data } = res.data;
-        if (success) {
-            localStorage.setItem('status', JSON.stringify(data));
-            statusDispatch({ type: 'set', payload: data });
-            localStorage.setItem('system_name', data.system_name);
-            localStorage.setItem('logo', data.logo);
-            localStorage.setItem('footer_html', data.footer_html);
-            localStorage.setItem('quota_per_unit', data.quota_per_unit);
-            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);
-            if (data.chat_link) {
-                localStorage.setItem('chat_link', data.chat_link);
-            } else {
-                localStorage.removeItem('chat_link');
-            }
-            if (data.chat_link2) {
-                localStorage.setItem('chat_link2', data.chat_link2);
-            } else {
-                localStorage.removeItem('chat_link2');
-            }
-        } else {
-            showError('无法正常连接至服务器!');
-        }
-    };
+  const loadStatus = async () => {
+    const res = await API.get('/api/status');
+    const { success, data } = res.data;
+    if (success) {
+      localStorage.setItem('status', JSON.stringify(data));
+      statusDispatch({ type: 'set', payload: data });
+      localStorage.setItem('system_name', data.system_name);
+      localStorage.setItem('logo', data.logo);
+      localStorage.setItem('footer_html', data.footer_html);
+      localStorage.setItem('quota_per_unit', data.quota_per_unit);
+      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);
+      if (data.chat_link) {
+        localStorage.setItem('chat_link', data.chat_link);
+      } else {
+        localStorage.removeItem('chat_link');
+      }
+      if (data.chat_link2) {
+        localStorage.setItem('chat_link2', data.chat_link2);
+      } else {
+        localStorage.removeItem('chat_link2');
+      }
+    } else {
+      showError('无法正常连接至服务器!');
+    }
+  };
 
-    useEffect(() => {
-        loadStatus().then(() => {
-            setIsCollapsed(isMobile() || localStorage.getItem('default_collapse_sidebar') === 'true');
-        });
-    },[])
+  useEffect(() => {
+    loadStatus().then(() => {
+      setIsCollapsed(isMobile() || localStorage.getItem('default_collapse_sidebar') === 'true');
+    });
+  }, []);
 
-    return (
-        <>
-            <Layout>
-                <div style={{height: '100%'}}>
-                    <Nav
-                        // bodyStyle={{ maxWidth: 200 }}
-                        style={{ maxWidth: 200 }}
-                        defaultIsCollapsed={isMobile() || localStorage.getItem('default_collapse_sidebar') === 'true'}
-                        isCollapsed={isCollapsed}
-                        onCollapseChange={collapsed => {
-                            setIsCollapsed(collapsed);
-                        }}
-                        selectedKeys={selectedKeys}
-                        renderWrapper={({itemElement, isSubNav, isInSubNav, props}) => {
-                            const routerMap = {
-                                home: "/",
-                                channel: "/channel",
-                                token: "/token",
-                                redemption: "/redemption",
-                                topup: "/topup",
-                                user: "/user",
-                                log: "/log",
-                                midjourney: "/midjourney",
-                                setting: "/setting",
-                                about: "/about",
-                                chat: "/chat",
-                                detail: "/detail",
-                            };
-                            return (
-                                <Link
-                                    style={{textDecoration: "none"}}
-                                    to={routerMap[props.itemKey]}
-                                >
-                                    {itemElement}
-                                </Link>
-                            );
-                        }}
-                        items={headerButtons}
-                        onSelect={key => {
-                            setSelectedKeys([key.itemKey]);
-                        }}
-                        header={{
-                            logo: <img src={logo} alt='logo' style={{marginRight: '0.75em'}}/>,
-                            text: systemName,
-                        }}
-                        // footer={{
-                        //   text: '© 2021 NekoAPI',
-                        // }}
-                    >
+  return (
+    <>
+      <Layout>
+        <div style={{ height: '100%' }}>
+          <Nav
+            // bodyStyle={{ maxWidth: 200 }}
+            style={{ maxWidth: 200 }}
+            defaultIsCollapsed={isMobile() || localStorage.getItem('default_collapse_sidebar') === 'true'}
+            isCollapsed={isCollapsed}
+            onCollapseChange={collapsed => {
+              setIsCollapsed(collapsed);
+            }}
+            selectedKeys={selectedKeys}
+            renderWrapper={({ itemElement, isSubNav, isInSubNav, props }) => {
+              const routerMap = {
+                home: '/',
+                channel: '/channel',
+                token: '/token',
+                redemption: '/redemption',
+                topup: '/topup',
+                user: '/user',
+                log: '/log',
+                midjourney: '/midjourney',
+                setting: '/setting',
+                about: '/about',
+                chat: '/chat',
+                detail: '/detail'
+              };
+              return (
+                <Link
+                  style={{ textDecoration: 'none' }}
+                  to={routerMap[props.itemKey]}
+                >
+                  {itemElement}
+                </Link>
+              );
+            }}
+            items={headerButtons}
+            onSelect={key => {
+              setSelectedKeys([key.itemKey]);
+            }}
+            header={{
+              logo: <img src={logo} alt="logo" style={{ marginRight: '0.75em' }} />,
+              text: systemName
+            }}
+            // footer={{
+            //   text: '© 2021 NekoAPI',
+            // }}
+          >
 
-                        <Nav.Footer collapseButton={true}>
-                        </Nav.Footer>
-                    </Nav>
-                </div>
-            </Layout>
-        </>
-    );
+            <Nav.Footer collapseButton={true}>
+            </Nav.Footer>
+          </Nav>
+        </div>
+      </Layout>
+    </>
+  );
 };
 
 export default SiderBar;

+ 658 - 658
web/src/components/SystemSetting.js

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

+ 553 - 529
web/src/components/TokensTable.js

@@ -1,562 +1,586 @@
-import React, {useEffect, useState} from 'react';
-import {API, copy, isAdmin, showError, showSuccess, showWarning, timestamp2string} from '../helpers';
-
-import {ITEMS_PER_PAGE} from '../constants';
-import {renderQuota, stringToColor} from '../helpers/render';
-import {
-    Avatar,
-    Tag,
-    Table,
-    Button,
-    Popover,
-    Form,
-    Modal,
-    Popconfirm,
-    SplitButtonGroup,
-    Dropdown
-} from "@douyinfe/semi-ui";
-
-import {
-    IconTreeTriangleDown,
-} from '@douyinfe/semi-icons';
-import EditToken from "../pages/Token/EditToken";
+import React, { useEffect, useState } from 'react';
+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 { IconTreeTriangleDown } from '@douyinfe/semi-icons';
+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: 'next', text: 'ChatGPT Next Web', value: 'next' },
+  { key: 'ama', text: 'ChatGPT Web & Midjourney', value: 'ama' },
+  { 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: 'ama', text: 'ChatGPT Web & Midjourney', value: 'ama' },
+  { 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>;
-            } else {
-                return <Tag color='green' size='large'>已启用</Tag>;
-            }
-        case 2:
-            return <Tag color='red' size='large'> 已禁用 </Tag>;
-        case 3:
-            return <Tag color='yellow' size='large'> 已过期 </Tag>;
-        case 4:
-            return <Tag color='grey' size='large'> 已耗尽 </Tag>;
-        default:
-            return <Tag color='black' size='large'> 未知状态 </Tag>;
-    }
+  switch (status) {
+    case 1:
+      if (model_limits_enabled) {
+        return <Tag color="green" size="large">已启用:限制模型</Tag>;
+      } else {
+        return <Tag color="green" size="large">已启用</Tag>;
+      }
+    case 2:
+      return <Tag color="red" size="large"> 已禁用 </Tag>;
+    case 3:
+      return <Tag color="yellow" size="large"> 已过期 </Tag>;
+    case 4:
+      return <Tag color="grey" size="large"> 已耗尽 </Tag>;
+    default:
+      return <Tag color="black" size="large"> 未知状态 </Tag>;
+  }
 }
 
 const TokensTable = () => {
 
-    const link_menu = [
-        {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: () => {onOpenLink('next-mj')}},
-        {node: 'item', key: 'opencat', name: 'OpenCat', value: 'opencat'},
-    ];
-
-    const columns = [
-        {
-            title: '名称',
-            dataIndex: 'name',
-        },
-        {
-            title: '状态',
-            dataIndex: 'status',
-            key: 'status',
-            render: (text, record, index) => {
-                return (
-                    <div>
-                        {renderStatus(text, record.model_limits_enabled)}
-                    </div>
-                );
-            },
-        },
-        {
-            title: '已用额度',
-            dataIndex: 'used_quota',
-            render: (text, record, index) => {
-                return (
-                    <div>
-                        {renderQuota(parseInt(text))}
-                    </div>
-                );
-            },
-        },
-        {
-            title: '剩余额度',
-            dataIndex: 'remain_quota',
-            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>}
-                    </div>
-                );
-            },
-        },
-        {
-            title: '创建时间',
-            dataIndex: 'created_time',
-            render: (text, record, index) => {
-                return (
-                    <div>
-                        {renderTimestamp(text)}
-                    </div>
-                );
-            },
-        },
-        {
-            title: '过期时间',
-            dataIndex: 'expired_time',
-            render: (text, record, index) => {
-                return (
-                    <div>
-                        {record.expired_time === -1 ? "永不过期" : renderTimestamp(text)}
-                    </div>
-                );
-            },
-        },
-        {
-            title: '',
-            dataIndex: 'operate',
-            render: (text, record, index) => (
-                <div>
-                    <Popover
-                        content={
-                            'sk-' + 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('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', disabled: !localStorage.getItem('chat_link'), name: 'ChatGPT Next Web', onClick: () => {onOpenLink('next', record.key)}},
-                                {node: 'item', key: 'next-mj', disabled: !localStorage.getItem('chat_link2'), name: 'ChatGPT Web & Midjourney', onClick: () => {onOpenLink('next-mj', record.key)}},
-                                {node: 'item', key: 'ama', name: 'AMA 问天(BotGem)', onClick: () => {onOpenLink('ama', record.key)}},
-                                {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>
-                        </Dropdown>
-                    </SplitButtonGroup>
-                    <Popconfirm
-                        title="确定是否要删除此令牌?"
-                        content="此修改将不可逆"
-                        okType={'danger'}
-                        position={'left'}
-                        onConfirm={() => {
-                            manageToken(record.id, 'delete', record).then(
-                                () => {
-                                    removeRecord(record.key);
-                                }
-                            )
-                        }}
-                    >
-                        <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={
-                        () => {
-                            setEditingToken(record);
-                            setShowEdit(true);
-                        }
-                    }>编辑</Button>
-                </div>
-            ),
-        },
-    ];
-
-    const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
-    const [showEdit, setShowEdit] = useState(false);
-    const [tokens, setTokens] = useState([]);
-    const [selectedKeys, setSelectedKeys] = useState([]);
-    const [tokenCount, setTokenCount] = useState(pageSize);
-    const [loading, setLoading] = useState(true);
-    const [activePage, setActivePage] = useState(1);
-    const [searchKeyword, setSearchKeyword] = useState('');
-    const [searchToken, setSearchToken] = useState('');
-    const [searching, setSearching] = useState(false);
-    const [showTopUpModal, setShowTopUpModal] = useState(false);
-    const [targetTokenIdx, setTargetTokenIdx] = useState(0);
-    const [editingToken, setEditingToken] = useState({
-        id: undefined,
-    });
-
-    const closeEdit = () => {
-        setShowEdit(false);
-        setTimeout(() => {
-            setEditingToken({
-                id: undefined,
-            });
-        }, 500);
-    }
-
-    const setTokensFormat = (tokens) => {
-        setTokens(tokens);
-        if (tokens.length >= pageSize) {
-            setTokenCount(tokens.length + pageSize);
-        } else {
-            setTokenCount(tokens.length);
-        }
-    }
-
-    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}`);
-        const {success, message, data} = res.data;
-        if (success) {
-            if (startIdx === 0) {
-                setTokensFormat(data);
-            } else {
-                let newTokens = [...tokens];
-                newTokens.splice(startIdx * pageSize, data.length, ...data);
-                setTokensFormat(newTokens);
+  const link_menu = [
+    {
+      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: () => {
+        onOpenLink('next-mj');
+      }
+    },
+    { node: 'item', key: 'opencat', name: 'OpenCat', value: 'opencat' }
+  ];
+
+  const columns = [
+    {
+      title: '名称',
+      dataIndex: 'name'
+    },
+    {
+      title: '状态',
+      dataIndex: 'status',
+      key: 'status',
+      render: (text, record, index) => {
+        return (
+          <div>
+            {renderStatus(text, record.model_limits_enabled)}
+          </div>
+        );
+      }
+    },
+    {
+      title: '已用额度',
+      dataIndex: 'used_quota',
+      render: (text, record, index) => {
+        return (
+          <div>
+            {renderQuota(parseInt(text))}
+          </div>
+        );
+      }
+    },
+    {
+      title: '剩余额度',
+      dataIndex: 'remain_quota',
+      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>}
+          </div>
+        );
+      }
+    },
+    {
+      title: '创建时间',
+      dataIndex: 'created_time',
+      render: (text, record, index) => {
+        return (
+          <div>
+            {renderTimestamp(text)}
+          </div>
+        );
+      }
+    },
+    {
+      title: '过期时间',
+      dataIndex: 'expired_time',
+      render: (text, record, index) => {
+        return (
+          <div>
+            {record.expired_time === -1 ? '永不过期' : renderTimestamp(text)}
+          </div>
+        );
+      }
+    },
+    {
+      title: '',
+      dataIndex: 'operate',
+      render: (text, record, index) => (
+        <div>
+          <Popover
+            content={
+              'sk-' + record.key
             }
-        } else {
-            showError(message);
-        }
-        setLoading(false);
-    };
-
-    const onPaginationChange = (e, {activePage}) => {
-        (async () => {
-            if (activePage === Math.ceil(tokens.length / pageSize) + 1) {
-                // In this case we have to load more data and then append them.
-                await loadTokens(activePage - 1);
+            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('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',
+                  disabled: !localStorage.getItem('chat_link'),
+                  name: 'ChatGPT Next Web',
+                  onClick: () => {
+                    onOpenLink('next', record.key);
+                  }
+                },
+                {
+                  node: 'item',
+                  key: 'next-mj',
+                  disabled: !localStorage.getItem('chat_link2'),
+                  name: 'ChatGPT Web & Midjourney',
+                  onClick: () => {
+                    onOpenLink('next-mj', record.key);
+                  }
+                },
+                {
+                  node: 'item', key: 'ama', name: 'AMA 问天(BotGem)', onClick: () => {
+                    onOpenLink('ama', record.key);
+                  }
+                },
+                {
+                  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>
+            </Dropdown>
+          </SplitButtonGroup>
+          <Popconfirm
+            title="确定是否要删除此令牌?"
+            content="此修改将不可逆"
+            okType={'danger'}
+            position={'left'}
+            onConfirm={() => {
+              manageToken(record.id, 'delete', record).then(
+                () => {
+                  removeRecord(record.key);
+                }
+              );
+            }}
+          >
+            <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={
+            () => {
+              setEditingToken(record);
+              setShowEdit(true);
             }
-            setActivePage(activePage);
-        })();
-    };
+          }>编辑</Button>
+        </div>
+      )
+    }
+  ];
+
+  const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
+  const [showEdit, setShowEdit] = useState(false);
+  const [tokens, setTokens] = useState([]);
+  const [selectedKeys, setSelectedKeys] = useState([]);
+  const [tokenCount, setTokenCount] = useState(pageSize);
+  const [loading, setLoading] = useState(true);
+  const [activePage, setActivePage] = useState(1);
+  const [searchKeyword, setSearchKeyword] = useState('');
+  const [searchToken, setSearchToken] = useState('');
+  const [searching, setSearching] = useState(false);
+  const [showTopUpModal, setShowTopUpModal] = useState(false);
+  const [targetTokenIdx, setTargetTokenIdx] = useState(0);
+  const [editingToken, setEditingToken] = useState({
+    id: undefined
+  });
+
+  const closeEdit = () => {
+    setShowEdit(false);
+    setTimeout(() => {
+      setEditingToken({
+        id: undefined
+      });
+    }, 500);
+  };
+
+  const setTokensFormat = (tokens) => {
+    setTokens(tokens);
+    if (tokens.length >= pageSize) {
+      setTokenCount(tokens.length + pageSize);
+    } else {
+      setTokenCount(tokens.length);
+    }
+  };
+
+  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}`);
+    const { success, message, data } = res.data;
+    if (success) {
+      if (startIdx === 0) {
+        setTokensFormat(data);
+      } else {
+        let newTokens = [...tokens];
+        newTokens.splice(startIdx * pageSize, data.length, ...data);
+        setTokensFormat(newTokens);
+      }
+    } else {
+      showError(message);
+    }
+    setLoading(false);
+  };
 
-    const refresh = async () => {
+  const onPaginationChange = (e, { activePage }) => {
+    (async () => {
+      if (activePage === Math.ceil(tokens.length / pageSize) + 1) {
+        // In this case we have to load more data and then append them.
         await loadTokens(activePage - 1);
-    };
-
-    const onCopy = async (type, key) => {
-        let status = localStorage.getItem('status');
-        let serverAddress = '';
-        if (status) {
-            status = JSON.parse(status);
-            serverAddress = status.server_address;
-        }
-        if (serverAddress === '') {
-            serverAddress = window.location.origin;
-        }
-        let encodedServerAddress = encodeURIComponent(serverAddress);
-        const nextLink = localStorage.getItem('chat_link');
-        const mjLink = localStorage.getItem('chat_link2');
-        let nextUrl;
-
-        if (nextLink) {
-            nextUrl = nextLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
-        } else {
-            nextUrl = `https://chat.oneapi.pro/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
-        }
-
-        let url;
-        switch (type) {
-            case 'ama':
-                url = mjLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
-                break;
-            case 'opencat':
-                url = `opencat://team/join?domain=${encodedServerAddress}&token=sk-${key}`;
-                break;
-            case 'next':
-                url = nextUrl;
-                break;
-            default:
-                url = `sk-${key}`;
-        }
-        // if (await copy(url)) {
-        //     showSuccess('已复制到剪贴板!');
-        // } else {
-        //     showWarning('无法复制到剪贴板,请手动复制,已将令牌填入搜索框。');
-        //     setSearchKeyword(url);
-        // }
-    };
-
-    const copyText = async (text) => {
-        if (await copy(text)) {
-            showSuccess('已复制到剪贴板!');
-        } else {
-            // setSearchKeyword(text);
-            Modal.error({ title: '无法复制到剪贴板,请手动复制', content: text });
-        }
+      }
+      setActivePage(activePage);
+    })();
+  };
+
+  const refresh = async () => {
+    await loadTokens(activePage - 1);
+  };
+
+  const onCopy = async (type, key) => {
+    let status = localStorage.getItem('status');
+    let serverAddress = '';
+    if (status) {
+      status = JSON.parse(status);
+      serverAddress = status.server_address;
+    }
+    if (serverAddress === '') {
+      serverAddress = window.location.origin;
+    }
+    let encodedServerAddress = encodeURIComponent(serverAddress);
+    const nextLink = localStorage.getItem('chat_link');
+    const mjLink = localStorage.getItem('chat_link2');
+    let nextUrl;
+
+    if (nextLink) {
+      nextUrl = nextLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
+    } else {
+      nextUrl = `https://chat.oneapi.pro/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
     }
 
-    const onOpenLink = async (type, key) => {
-        let status = localStorage.getItem('status');
-        let serverAddress = '';
-        if (status) {
-            status = JSON.parse(status);
-            serverAddress = status.server_address;
-        }
-        if (serverAddress === '') {
-            serverAddress = window.location.origin;
-        }
-        let encodedServerAddress = encodeURIComponent(serverAddress);
-        const chatLink = localStorage.getItem('chat_link');
-        const mjLink = localStorage.getItem('chat_link2');
-        let defaultUrl;
+    let url;
+    switch (type) {
+      case 'ama':
+        url = mjLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
+        break;
+      case 'opencat':
+        url = `opencat://team/join?domain=${encodedServerAddress}&token=sk-${key}`;
+        break;
+      case 'next':
+        url = nextUrl;
+        break;
+      default:
+        url = `sk-${key}`;
+    }
+    // if (await copy(url)) {
+    //     showSuccess('已复制到剪贴板!');
+    // } else {
+    //     showWarning('无法复制到剪贴板,请手动复制,已将令牌填入搜索框。');
+    //     setSearchKeyword(url);
+    // }
+  };
+
+  const copyText = async (text) => {
+    if (await copy(text)) {
+      showSuccess('已复制到剪贴板!');
+    } else {
+      // setSearchKeyword(text);
+      Modal.error({ title: '无法复制到剪贴板,请手动复制', content: text });
+    }
+  };
+
+  const onOpenLink = async (type, key) => {
+    let status = localStorage.getItem('status');
+    let serverAddress = '';
+    if (status) {
+      status = JSON.parse(status);
+      serverAddress = status.server_address;
+    }
+    if (serverAddress === '') {
+      serverAddress = window.location.origin;
+    }
+    let encodedServerAddress = encodeURIComponent(serverAddress);
+    const chatLink = localStorage.getItem('chat_link');
+    const mjLink = localStorage.getItem('chat_link2');
+    let defaultUrl;
 
-        if (chatLink) {
-            defaultUrl = chatLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
-        }
-        let url;
-        switch (type) {
-            case 'ama':
-                url = `ama://set-api-key?server=${encodedServerAddress}&key=sk-${key}`;
-                break;
-            case 'opencat':
-                url = `opencat://team/join?domain=${encodedServerAddress}&token=sk-${key}`;
-                break;
-            case 'next-mj':
-                url =  mjLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
-                break;
-            default:
-                if (!chatLink) {
-                    showError('管理员未设置聊天链接')
-                    return;
-                }
-                url = defaultUrl;
+    if (chatLink) {
+      defaultUrl = chatLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
+    }
+    let url;
+    switch (type) {
+      case 'ama':
+        url = `ama://set-api-key?server=${encodedServerAddress}&key=sk-${key}`;
+        break;
+      case 'opencat':
+        url = `opencat://team/join?domain=${encodedServerAddress}&token=sk-${key}`;
+        break;
+      case 'next-mj':
+        url = mjLink + `/#/?settings={"key":"sk-${key}","url":"${serverAddress}"}`;
+        break;
+      default:
+        if (!chatLink) {
+          showError('管理员未设置聊天链接');
+          return;
         }
+        url = defaultUrl;
+    }
 
-        window.open(url, '_blank');
+    window.open(url, '_blank');
+  };
+
+  useEffect(() => {
+    loadTokens(0)
+      .then()
+      .catch((reason) => {
+        showError(reason);
+      });
+  }, [pageSize]);
+
+  const removeRecord = key => {
+    let newDataSource = [...tokens];
+    if (key != null) {
+      let idx = newDataSource.findIndex(data => data.key === key);
+
+      if (idx > -1) {
+        newDataSource.splice(idx, 1);
+        setTokensFormat(newDataSource);
+      }
     }
+  };
+
+  const manageToken = async (id, action, record) => {
+    setLoading(true);
+    let data = { id };
+    let res;
+    switch (action) {
+      case 'delete':
+        res = await API.delete(`/api/token/${id}/`);
+        break;
+      case 'enable':
+        data.status = 1;
+        res = await API.put('/api/token/?status_only=true', data);
+        break;
+      case 'disable':
+        data.status = 2;
+        res = await API.put('/api/token/?status_only=true', data);
+        break;
+    }
+    const { success, message } = res.data;
+    if (success) {
+      showSuccess('操作成功完成!');
+      let token = res.data.data;
+      let newTokens = [...tokens];
+      // let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
+      if (action === 'delete') {
+
+      } else {
+        record.status = token.status;
+        // newTokens[realIdx].status = token.status;
+      }
+      setTokensFormat(newTokens);
+    } else {
+      showError(message);
+    }
+    setLoading(false);
+  };
+
+  const searchTokens = async () => {
+    if (searchKeyword === '' && searchToken === '') {
+      // if keyword is blank, load files instead.
+      await loadTokens(0);
+      setActivePage(1);
+      return;
+    }
+    setSearching(true);
+    const res = await API.get(`/api/token/search?keyword=${searchKeyword}&token=${searchToken}`);
+    const { success, message, data } = res.data;
+    if (success) {
+      setTokensFormat(data);
+      setActivePage(1);
+    } else {
+      showError(message);
+    }
+    setSearching(false);
+  };
+
+  const handleKeywordChange = async (value) => {
+    setSearchKeyword(value.trim());
+  };
+
+  const handleSearchTokenChange = async (value) => {
+    setSearchToken(value.trim());
+  };
+
+  const sortToken = (key) => {
+    if (tokens.length === 0) return;
+    setLoading(true);
+    let sortedTokens = [...tokens];
+    sortedTokens.sort((a, b) => {
+      return ('' + a[key]).localeCompare(b[key]);
+    });
+    if (sortedTokens[0].id === tokens[0].id) {
+      sortedTokens.reverse();
+    }
+    setTokens(sortedTokens);
+    setLoading(false);
+  };
 
-    useEffect(() => {
-        loadTokens(0)
-            .then()
-            .catch((reason) => {
-                showError(reason);
-            });
-    }, [pageSize]);
-
-    const removeRecord = key => {
-        let newDataSource = [...tokens];
-        if (key != null) {
-            let idx = newDataSource.findIndex(data => data.key === key);
-
-            if (idx > -1) {
-                newDataSource.splice(idx, 1);
-                setTokensFormat(newDataSource);
-            }
-        }
-    };
-
-    const manageToken = async (id, action, record) => {
-        setLoading(true);
-        let data = {id};
-        let res;
-        switch (action) {
-            case 'delete':
-                res = await API.delete(`/api/token/${id}/`);
-                break;
-            case 'enable':
-                data.status = 1;
-                res = await API.put('/api/token/?status_only=true', data);
-                break;
-            case 'disable':
-                data.status = 2;
-                res = await API.put('/api/token/?status_only=true', data);
-                break;
-        }
-        const {success, message} = res.data;
-        if (success) {
-            showSuccess('操作成功完成!');
-            let token = res.data.data;
-            let newTokens = [...tokens];
-            // let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
-            if (action === 'delete') {
-
-            } else {
-                record.status = token.status;
-                // newTokens[realIdx].status = token.status;
-            }
-            setTokensFormat(newTokens);
-        } else {
-            showError(message);
-        }
-        setLoading(false);
-    };
-
-    const searchTokens = async () => {
-        if (searchKeyword === '' && searchToken === '') {
-            // if keyword is blank, load files instead.
-            await loadTokens(0);
-            setActivePage(1);
-            return;
-        }
-        setSearching(true);
-        const res = await API.get(`/api/token/search?keyword=${searchKeyword}&token=${searchToken}`);
-        const {success, message, data} = res.data;
-        if (success) {
-            setTokensFormat(data);
-            setActivePage(1);
-        } else {
-            showError(message);
-        }
-        setSearching(false);
-    };
-
-    const handleKeywordChange = async (value) => {
-        setSearchKeyword(value.trim());
-    };
-
-    const handleSearchTokenChange = async (value) => {
-        setSearchToken(value.trim());
-    };
-
-    const sortToken = (key) => {
-        if (tokens.length === 0) return;
-        setLoading(true);
-        let sortedTokens = [...tokens];
-        sortedTokens.sort((a, b) => {
-            return ('' + a[key]).localeCompare(b[key]);
-        });
-        if (sortedTokens[0].id === tokens[0].id) {
-            sortedTokens.reverse();
-        }
-        setTokens(sortedTokens);
-        setLoading(false);
-    };
 
+  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 => {
+      });
+    }
+  };
+
+  const rowSelection = {
+    onSelect: (record, selected) => {
+    },
+    onSelectAll: (selected, selectedRows) => {
+    },
+    onChange: (selectedRowKeys, selectedRows) => {
+      setSelectedKeys(selectedRows);
+    }
+  };
 
-    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 => {
-            });
+  const handleRow = (record, index) => {
+    if (record.status !== 1) {
+      return {
+        style: {
+          background: 'var(--semi-color-disabled-border)'
         }
-    };
-
-    const rowSelection = {
-        onSelect: (record, selected) => {
-        },
-        onSelectAll: (selected, selectedRows) => {
-        },
-        onChange: (selectedRowKeys, selectedRows) => {
-            setSelectedKeys(selectedRows);
+      };
+    } else {
+      return {};
+    }
+  };
+
+  return (
+    <>
+      <EditToken refresh={refresh} editingToken={editingToken} visiable={showEdit} handleClose={closeEdit}></EditToken>
+      <Form layout="horizontal" style={{ marginTop: 10 }} labelPosition={'left'}>
+        <Form.Input
+          field="keyword"
+          label="搜索关键字"
+          placeholder="令牌名称"
+          value={searchKeyword}
+          loading={searching}
+          onChange={handleKeywordChange}
+        />
+        <Form.Input
+          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>
+      </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);
         },
-    };
-
-    const handleRow = (record, index) => {
-        if (record.status !== 1) {
-            return {
-                style: {
-                    background: 'var(--semi-color-disabled-border)',
-                },
-            };
-        } else {
-            return {};
+        onPageChange: handlePageChange
+      }} loading={loading} rowSelection={rowSelection} onRow={handleRow}>
+      </Table>
+      <Button theme="light" type="primary" style={{ marginRight: 8 }} onClick={
+        () => {
+          setEditingToken({
+            id: undefined
+          });
+          setShowEdit(true);
         }
-    };
-
-    return (
-        <>
-            <EditToken refresh={refresh} editingToken={editingToken} visiable={showEdit} handleClose={closeEdit}></EditToken>
-            <Form layout='horizontal' style={{marginTop: 10}} labelPosition={'left'}>
-                <Form.Input
-                    field="keyword"
-                    label='搜索关键字'
-                    placeholder='令牌名称'
-                    value={searchKeyword}
-                    loading={searching}
-                    onChange={handleKeywordChange}
-                />
-                <Form.Input
-                    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>
-            </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={
-                () => {
-                    setEditingToken({
-                        id: undefined,
-                    });
-                    setShowEdit(true);
-                }
-            }>添加令牌</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";
-                    }
-                    await copyText(keys);
-                }
-            }>复制所选令牌到剪贴板</Button>
-        </>
-    );
+      }>添加令牌</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';
+          }
+          await copyText(keys);
+        }
+      }>复制所选令牌到剪贴板</Button>
+    </>
+  );
 };
 
 export default TokensTable;

+ 306 - 305
web/src/components/UsersTable.js

@@ -1,337 +1,338 @@
-import React, {useEffect, useState} from 'react';
-import {API, isAdmin, showError, showSuccess} from '../helpers';
-import {Button, Modal, Popconfirm, Popover, Table, Tag, Form, Tooltip, Space} from "@douyinfe/semi-ui";
-import {ITEMS_PER_PAGE} from '../constants';
-import {renderGroup, renderNumber, renderQuota, renderText, stringToColor} from '../helpers/render';
-import AddUser from "../pages/User/AddUser";
-import EditUser from "../pages/User/EditUser";
+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 { ITEMS_PER_PAGE } from '../constants';
+import { renderGroup, renderNumber, renderQuota } from '../helpers/render';
+import AddUser from '../pages/User/AddUser';
+import EditUser from '../pages/User/EditUser';
 
 function renderRole(role) {
-    switch (role) {
-        case 1:
-            return <Tag size='large'>普通用户</Tag>;
-        case 10:
-            return <Tag color='yellow' size='large'>管理员</Tag>;
-        case 100:
-            return <Tag color='orange' size='large'>超级管理员</Tag>;
-        default:
-            return <Tag color='red' size='large'>未知身份</Tag>;
-    }
+  switch (role) {
+    case 1:
+      return <Tag size="large">普通用户</Tag>;
+    case 10:
+      return <Tag color="yellow" size="large">管理员</Tag>;
+    case 100:
+      return <Tag color="orange" size="large">超级管理员</Tag>;
+    default:
+      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>
-                    </>
-
-            }
+  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="确定是否要删除此用户?"
-                content="硬删除,此修改将不可逆"
-                okType={'danger'}
-                position={'left'}
-                onConfirm={() => {
-                    manageUser(record.username, 'delete', record).then(() => {
-                        removeRecord(record.id);
-                    })
-                }}
+              title="确定?"
+              okType={'warning'}
+              onConfirm={() => {
+                manageUser(record.username, 'demote', record);
+              }}
             >
-                <Button theme='light' type='danger' style={{marginRight: 1}}>删除</Button>
+              <Button theme="light" type="secondary" style={{ marginRight: 1 }}>降级</Button>
             </Popconfirm>
-        </div>),
-    },];
+            {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>
+          </>
 
-    const [users, setUsers] = useState([]);
-    const [loading, setLoading] = useState(true);
-    const [activePage, setActivePage] = useState(1);
-    const [searchKeyword, setSearchKeyword] = useState('');
-    const [searching, setSearching] = useState(false);
-    const [userCount, setUserCount] = useState(ITEMS_PER_PAGE);
-    const [showAddUser, setShowAddUser] = useState(false);
-    const [showEditUser, setShowEditUser] = useState(false);
-    const [editingUser, setEditingUser] = useState({
-        id: undefined,
-    });
+      }
+      <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 setCount = (data) => {
-        if (data.length >= (activePage) * ITEMS_PER_PAGE) {
-            setUserCount(data.length + 1);
-        } else {
-            setUserCount(data.length);
-        }
+  const [users, setUsers] = useState([]);
+  const [loading, setLoading] = useState(true);
+  const [activePage, setActivePage] = useState(1);
+  const [searchKeyword, setSearchKeyword] = useState('');
+  const [searching, setSearching] = useState(false);
+  const [userCount, setUserCount] = useState(ITEMS_PER_PAGE);
+  const [showAddUser, setShowAddUser] = useState(false);
+  const [showEditUser, setShowEditUser] = useState(false);
+  const [editingUser, setEditingUser] = useState({
+    id: undefined
+  });
+
+  const setCount = (data) => {
+    if (data.length >= (activePage) * ITEMS_PER_PAGE) {
+      setUserCount(data.length + 1);
+    } else {
+      setUserCount(data.length);
     }
+  };
 
-    const removeRecord = key => {
-        console.log(key);
-        let newDataSource = [...users];
-        if (key != null) {
-            let idx = newDataSource.findIndex(data => data.id === key);
+  const removeRecord = key => {
+    console.log(key);
+    let newDataSource = [...users];
+    if (key != null) {
+      let idx = newDataSource.findIndex(data => data.id === key);
 
-            if (idx > -1) {
-                newDataSource.splice(idx, 1);
-                setUsers(newDataSource);
-            }
-        }
-    };
+      if (idx > -1) {
+        newDataSource.splice(idx, 1);
+        setUsers(newDataSource);
+      }
+    }
+  };
 
-    const loadUsers = async (startIdx) => {
-        const res = await API.get(`/api/user/?p=${startIdx}`);
-        const {success, message, data} = res.data;
-        if (success) {
-            if (startIdx === 0) {
-                setUsers(data);
-                setCount(data);
-            } else {
-                let newUsers = users;
-                newUsers.push(...data);
-                setUsers(newUsers);
-                setCount(newUsers);
-            }
-        } else {
-            showError(message);
-        }
-        setLoading(false);
-    };
+  const loadUsers = async (startIdx) => {
+    const res = await API.get(`/api/user/?p=${startIdx}`);
+    const { success, message, data } = res.data;
+    if (success) {
+      if (startIdx === 0) {
+        setUsers(data);
+        setCount(data);
+      } else {
+        let newUsers = users;
+        newUsers.push(...data);
+        setUsers(newUsers);
+        setCount(newUsers);
+      }
+    } else {
+      showError(message);
+    }
+    setLoading(false);
+  };
 
-    const onPaginationChange = (e, {activePage}) => {
-        (async () => {
-            if (activePage === Math.ceil(users.length / ITEMS_PER_PAGE) + 1) {
-                // In this case we have to load more data and then append them.
-                await loadUsers(activePage - 1);
-            }
-            setActivePage(activePage);
-        })();
-    };
+  const onPaginationChange = (e, { activePage }) => {
+    (async () => {
+      if (activePage === Math.ceil(users.length / ITEMS_PER_PAGE) + 1) {
+        // In this case we have to load more data and then append them.
+        await loadUsers(activePage - 1);
+      }
+      setActivePage(activePage);
+    })();
+  };
 
-    useEffect(() => {
-        loadUsers(0)
-            .then()
-            .catch((reason) => {
-                showError(reason);
-            });
-    }, []);
+  useEffect(() => {
+    loadUsers(0)
+      .then()
+      .catch((reason) => {
+        showError(reason);
+      });
+  }, []);
 
-    const manageUser = async (username, action, record) => {
-        const res = await API.post('/api/user/manage', {
-            username, action
-        });
-        const {success, message} = res.data;
-        if (success) {
-            showSuccess('操作成功完成!');
-            let user = res.data.data;
-            let newUsers = [...users];
-            if (action === 'delete') {
+  const manageUser = async (username, action, record) => {
+    const res = await API.post('/api/user/manage', {
+      username, action
+    });
+    const { success, message } = res.data;
+    if (success) {
+      showSuccess('操作成功完成!');
+      let user = res.data.data;
+      let newUsers = [...users];
+      if (action === 'delete') {
 
-            } else {
-                record.status = user.status;
-                record.role = user.role;
-            }
-            setUsers(newUsers);
-        } else {
-            showError(message);
-        }
-    };
+      } else {
+        record.status = user.status;
+        record.role = user.role;
+      }
+      setUsers(newUsers);
+    } else {
+      showError(message);
+    }
+  };
 
-    const renderStatus = (status) => {
-        switch (status) {
-            case 1:
-                return <Tag size='large'>已激活</Tag>;
-            case 2:
-                return (<Tag size='large' color='red'>
-                    已封禁
-                </Tag>);
-            default:
-                return (<Tag size='large' color='grey'>
-                    未知状态
-                </Tag>);
-        }
-    };
+  const renderStatus = (status) => {
+    switch (status) {
+      case 1:
+        return <Tag size="large">已激活</Tag>;
+      case 2:
+        return (<Tag size="large" color="red">
+          已封禁
+        </Tag>);
+      default:
+        return (<Tag size="large" color="grey">
+          未知状态
+        </Tag>);
+    }
+  };
 
-    const searchUsers = async () => {
-        if (searchKeyword === '') {
-            // if keyword is blank, load files instead.
-            await loadUsers(0);
-            setActivePage(1);
-            return;
-        }
-        setSearching(true);
-        const res = await API.get(`/api/user/search?keyword=${searchKeyword}`);
-        const {success, message, data} = res.data;
-        if (success) {
-            setUsers(data);
-            setActivePage(1);
-        } else {
-            showError(message);
-        }
-        setSearching(false);
-    };
+  const searchUsers = async () => {
+    if (searchKeyword === '') {
+      // if keyword is blank, load files instead.
+      await loadUsers(0);
+      setActivePage(1);
+      return;
+    }
+    setSearching(true);
+    const res = await API.get(`/api/user/search?keyword=${searchKeyword}`);
+    const { success, message, data } = res.data;
+    if (success) {
+      setUsers(data);
+      setActivePage(1);
+    } else {
+      showError(message);
+    }
+    setSearching(false);
+  };
 
-    const handleKeywordChange = async (value) => {
-        setSearchKeyword(value.trim());
-    };
+  const handleKeywordChange = async (value) => {
+    setSearchKeyword(value.trim());
+  };
 
-    const sortUser = (key) => {
-        if (users.length === 0) return;
-        setLoading(true);
-        let sortedUsers = [...users];
-        sortedUsers.sort((a, b) => {
-            return ('' + a[key]).localeCompare(b[key]);
-        });
-        if (sortedUsers[0].id === users[0].id) {
-            sortedUsers.reverse();
-        }
-        setUsers(sortedUsers);
-        setLoading(false);
-    };
+  const sortUser = (key) => {
+    if (users.length === 0) return;
+    setLoading(true);
+    let sortedUsers = [...users];
+    sortedUsers.sort((a, b) => {
+      return ('' + a[key]).localeCompare(b[key]);
+    });
+    if (sortedUsers[0].id === users[0].id) {
+      sortedUsers.reverse();
+    }
+    setUsers(sortedUsers);
+    setLoading(false);
+  };
 
-    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 => {
-            });
-        }
-    };
+  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 => {
+      });
+    }
+  };
 
-    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);
-    }
+  const closeAddUser = () => {
+    setShowAddUser(false);
+  };
 
-    const closeEditUser = () => {
-        setShowEditUser(false);
-        setEditingUser({
-            id: undefined,
-        });
-    }
+  const closeEditUser = () => {
+    setShowEditUser(false);
+    setEditingUser({
+      id: undefined
+    });
+  };
 
-    const refresh = async () => {
-        if (searchKeyword === '') {
-            await loadUsers(activePage - 1);
-        } else {
-            await searchUsers();
-        }
-    };
+  const refresh = async () => {
+    if (searchKeyword === '') {
+      await loadUsers(activePage - 1);
+    } else {
+      await searchUsers();
+    }
+  };
 
-    return (
-        <>
-            <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,用户名,显示名称,以及邮箱地址 ...'
-                    value={searchKeyword}
-                    loading={searching}
-                    onChange={value => handleKeywordChange(value)}
-                />
-            </Form>
+  return (
+    <>
+      <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,用户名,显示名称,以及邮箱地址 ..."
+          value={searchKeyword}
+          loading={searching}
+          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={
-                () => {
-                    setShowAddUser(true);
-                }
-            }>添加用户</Button>
-        </>
-    );
+      <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>
+    </>
+  );
 };
 
 export default UsersTable;

+ 6 - 6
web/src/components/WeChatIcon.js

@@ -3,14 +3,14 @@ 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'>
+    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>
+        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>
+        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>;
   }
 

+ 74 - 74
web/src/pages/Redemption/EditRedemption.js

@@ -1,17 +1,17 @@
 import React, { useEffect, useState } from 'react';
-import { useParams, useNavigate } from 'react-router-dom';
-import {API, downloadTextAsFile, isMobile, showError, showSuccess} from '../../helpers';
-import { renderQuota, renderQuotaWithPrompt } from '../../helpers/render';
-import {SideSheet, Space, Spin, Button, Input, Typography, AutoComplete, Modal} from "@douyinfe/semi-ui";
-import Title from "@douyinfe/semi-ui/lib/es/typography/title";
-import {Divider} from "semantic-ui-react";
+import { useNavigate, useParams } from 'react-router-dom';
+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 Title from '@douyinfe/semi-ui/lib/es/typography/title';
+import { Divider } from 'semantic-ui-react';
 
 const EditRedemption = (props) => {
   const isEdit = props.editingRedemption.id !== undefined;
   const [loading, setLoading] = useState(isEdit);
 
   const params = useParams();
-  const navigate = useNavigate()
+  const navigate = useNavigate();
   const originInputs = {
     name: '',
     quota: 100000,
@@ -22,8 +22,8 @@ const EditRedemption = (props) => {
 
   const handleCancel = () => {
     props.handleClose();
-  }
-  
+  };
+
   const handleInputChange = (name, value) => {
     setInputs((inputs) => ({ ...inputs, [name]: value }));
   };
@@ -43,9 +43,9 @@ const EditRedemption = (props) => {
   useEffect(() => {
     if (isEdit) {
       loadRedemption().then(
-          () => {
-            // console.log(inputs);
-          }
+        () => {
+          // console.log(inputs);
+        }
       );
     } else {
       setInputs(originInputs);
@@ -82,21 +82,21 @@ const EditRedemption = (props) => {
       showError(message);
     }
     if (!isEdit && data) {
-      let text = "";
+      let text = '';
       for (let i = 0; i < data.length; i++) {
-        text += data[i] + "\n";
+        text += data[i] + '\n';
       }
       // downloadTextAsFile(text, `${inputs.name}.txt`);
       Modal.confirm({
         title: '兑换码创建成功',
         content: (
-            <div>
-                <p>兑换码创建成功,是否下载兑换码?</p>
-                <p>兑换码将以文本文件的形式下载,文件名为兑换码的名称。</p>
-            </div>
+          <div>
+            <p>兑换码创建成功,是否下载兑换码?</p>
+            <p>兑换码将以文本文件的形式下载,文件名为兑换码的名称。</p>
+          </div>
         ),
         onOk: () => {
-            downloadTextAsFile(text, `${inputs.name}.txt`);
+          downloadTextAsFile(text, `${inputs.name}.txt`);
         }
       });
     }
@@ -106,71 +106,71 @@ const EditRedemption = (props) => {
   return (
     <>
       <SideSheet
-          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.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>
-              </Space>
-            </div>
-          }
-          closeIcon={null}
-          onCancel={() => handleCancel()}
-          width={isMobile() ? '100%' : 600}
+        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.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>
+            </Space>
+          </div>
+        }
+        closeIcon={null}
+        onCancel={() => handleCancel()}
+        width={isMobile() ? '100%' : 600}
       >
         <Spin spinning={loading}>
           <Input
-              style={{ marginTop: 20 }}
-              label='名称'
-              name='name'
-              placeholder={'请输入名称'}
-              onChange={value => handleInputChange('name', value)}
-              value={name}
-              autoComplete='new-password'
-              required={!isEdit}
+            style={{ marginTop: 20 }}
+            label="名称"
+            name="name"
+            placeholder={'请输入名称'}
+            onChange={value => handleInputChange('name', value)}
+            value={name}
+            autoComplete="new-password"
+            required={!isEdit}
           />
-          <Divider/>
+          <Divider />
           <div style={{ marginTop: 20 }}>
             <Typography.Text>{`额度${renderQuotaWithPrompt(quota)}`}</Typography.Text>
           </div>
           <AutoComplete
-              style={{ marginTop: 8 }}
-              name='quota'
-              placeholder={'请输入额度'}
-              onChange={(value) => handleInputChange('quota', value)}
-              value={quota}
-              autoComplete='new-password'
-              type='number'
-              position={'bottom'}
-              data={[
-                {value: 500000, label: '1$'},
-                {value: 5000000, label: '10$'},
-                {value: 25000000, label: '50$'},
-                {value: 50000000, label: '100$'},
-                {value: 250000000, label: '500$'},
-                {value: 500000000, label: '1000$'},
-              ]}
+            style={{ marginTop: 8 }}
+            name="quota"
+            placeholder={'请输入额度'}
+            onChange={(value) => handleInputChange('quota', value)}
+            value={quota}
+            autoComplete="new-password"
+            type="number"
+            position={'bottom'}
+            data={[
+              { value: 500000, label: '1$' },
+              { value: 5000000, label: '10$' },
+              { value: 25000000, label: '50$' },
+              { value: 50000000, label: '100$' },
+              { value: 250000000, label: '500$' },
+              { value: 500000000, label: '1000$' }
+            ]}
           />
           {
-              !isEdit && <>
-                <Divider/>
-                <Typography.Text>生成数量</Typography.Text>
-                <Input
-                    style={{ marginTop: 8 }}
-                    label='生成数量'
-                    name='count'
-                    placeholder={'请输入生成数量'}
-                    onChange={value => handleInputChange('count', value)}
-                    value={count}
-                    autoComplete='new-password'
-                    type='number'
-                />
-              </>
+            !isEdit && <>
+              <Divider />
+              <Typography.Text>生成数量</Typography.Text>
+              <Input
+                style={{ marginTop: 8 }}
+                label="生成数量"
+                name="count"
+                placeholder={'请输入生成数量'}
+                onChange={value => handleInputChange('count', value)}
+                value={count}
+                autoComplete="new-password"
+                type="number"
+              />
+            </>
           }
         </Spin>
       </SideSheet>

+ 320 - 321
web/src/pages/Token/EditToken.js

@@ -1,352 +1,351 @@
-import React, {useEffect, useRef, useState} from 'react';
-import {useParams, useNavigate} from 'react-router-dom';
-import {API, isMobile, showError, showSuccess, timestamp2string} from '../../helpers';
-import {renderQuota, renderQuotaWithPrompt} from '../../helpers/render';
+import React, { useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { API, isMobile, showError, showSuccess, timestamp2string } from '../../helpers';
+import { renderQuotaWithPrompt } from '../../helpers/render';
 import {
-    Layout,
-    SideSheet,
+    AutoComplete,
+    Banner,
     Button,
+    Checkbox,
+    DatePicker,
+    Input,
+    Select,
+    SideSheet,
     Space,
     Spin,
-    Banner,
-    Input,
-    DatePicker,
-    AutoComplete,
-    Typography,
-    Checkbox, Select
-} from "@douyinfe/semi-ui";
-import Title from "@douyinfe/semi-ui/lib/es/typography/title";
-import {Divider} from "semantic-ui-react";
+    Typography
+} from '@douyinfe/semi-ui';
+import Title from '@douyinfe/semi-ui/lib/es/typography/title';
+import { Divider } from 'semantic-ui-react';
 
 const EditToken = (props) => {
-    const [isEdit, setIsEdit] = useState(false);
-    const [loading, setLoading] = useState(isEdit);
-    const originInputs = {
-        name: '',
-        remain_quota: isEdit ? 0 : 500000,
-        expired_time: -1,
-        unlimited_quota: false,
-        model_limits_enabled: false,
-        model_limits: [],
-    };
-    const [inputs, setInputs] = useState(originInputs);
-    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();
-    const handleInputChange = (name, value) => {
-        setInputs((inputs) => ({...inputs, [name]: value}));
-    };
-    const handleCancel = () => {
-        props.handleClose();
+  const [isEdit, setIsEdit] = useState(false);
+  const [loading, setLoading] = useState(isEdit);
+  const originInputs = {
+    name: '',
+    remain_quota: isEdit ? 0 : 500000,
+    expired_time: -1,
+    unlimited_quota: false,
+    model_limits_enabled: false,
+    model_limits: []
+  };
+  const [inputs, setInputs] = useState(originInputs);
+  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();
+  const handleInputChange = (name, value) => {
+    setInputs((inputs) => ({ ...inputs, [name]: value }));
+  };
+  const handleCancel = () => {
+    props.handleClose();
+  };
+  const setExpiredTime = (month, day, hour, minute) => {
+    let now = new Date();
+    let timestamp = now.getTime() / 1000;
+    let seconds = month * 30 * 24 * 60 * 60;
+    seconds += day * 24 * 60 * 60;
+    seconds += hour * 60 * 60;
+    seconds += minute * 60;
+    if (seconds !== 0) {
+      timestamp += seconds;
+      setInputs({ ...inputs, expired_time: timestamp2string(timestamp) });
+    } else {
+      setInputs({ ...inputs, expired_time: -1 });
     }
-    const setExpiredTime = (month, day, hour, minute) => {
-        let now = new Date();
-        let timestamp = now.getTime() / 1000;
-        let seconds = month * 30 * 24 * 60 * 60;
-        seconds += day * 24 * 60 * 60;
-        seconds += hour * 60 * 60;
-        seconds += minute * 60;
-        if (seconds !== 0) {
-            timestamp += seconds;
-            setInputs({...inputs, expired_time: timestamp2string(timestamp)});
-        } else {
-            setInputs({...inputs, expired_time: -1});
-        }
-    };
+  };
 
-    const setUnlimitedQuota = () => {
-        setInputs({...inputs, unlimited_quota: !unlimited_quota});
-    };
+  const setUnlimitedQuota = () => {
+    setInputs({ ...inputs, unlimited_quota: !unlimited_quota });
+  };
 
-    const loadModels = async () => {
-        let res = await API.get(`/api/user/models`);
-        const {success, message, data} = res.data;
-        if (success) {
-            let localModelOptions = data.map((model) => ({
-                label: model,
-                value: model
-            }));
-            setModels(localModelOptions);
-        } else {
-            showError(message);
-        }
+  const loadModels = async () => {
+    let res = await API.get(`/api/user/models`);
+    const { success, message, data } = res.data;
+    if (success) {
+      let localModelOptions = data.map((model) => ({
+        label: model,
+        value: model
+      }));
+      setModels(localModelOptions);
+    } else {
+      showError(message);
     }
+  };
 
-    const loadToken = async () => {
-        setLoading(true);
-        let res = await API.get(`/api/token/${props.editingToken.id}`);
-        const {success, message, data} = res.data;
-        if (success) {
-            if (data.expired_time !== -1) {
-                data.expired_time = timestamp2string(data.expired_time);
-            }
-            if (data.model_limits !== '') {
-                data.model_limits = data.model_limits.split(',');
-            } else {
-                data.model_limits = [];
-            }
-            setInputs(data);
-        } else {
-            showError(message);
+  const loadToken = async () => {
+    setLoading(true);
+    let res = await API.get(`/api/token/${props.editingToken.id}`);
+    const { success, message, data } = res.data;
+    if (success) {
+      if (data.expired_time !== -1) {
+        data.expired_time = timestamp2string(data.expired_time);
+      }
+      if (data.model_limits !== '') {
+        data.model_limits = data.model_limits.split(',');
+      } else {
+        data.model_limits = [];
+      }
+      setInputs(data);
+    } else {
+      showError(message);
+    }
+    setLoading(false);
+  };
+  useEffect(() => {
+    setIsEdit(props.editingToken.id !== undefined);
+  }, [props.editingToken.id]);
+
+  useEffect(() => {
+    if (!isEdit) {
+      setInputs(originInputs);
+    } else {
+      loadToken().then(
+        () => {
+          // console.log(inputs);
         }
-        setLoading(false);
-    };
-    useEffect(() => {
-        setIsEdit(props.editingToken.id !== undefined);
-    }, [props.editingToken.id]);
+      );
+    }
+    loadModels();
+  }, [isEdit]);
 
-    useEffect(() => {
-       if (!isEdit) {
-           setInputs(originInputs);
-       } else {
-           loadToken().then(
-               () => {
-                   // console.log(inputs);
-               }
-           );
-       }
-       loadModels();
-    }, [isEdit]);
+  // 新增 state 变量 tokenCount 来记录用户想要创建的令牌数量,默认为 1
+  const [tokenCount, setTokenCount] = useState(1);
 
-    // 新增 state 变量 tokenCount 来记录用户想要创建的令牌数量,默认为 1
-    const [tokenCount, setTokenCount] = useState(1);
+  // 新增处理 tokenCount 变化的函数
+  const handleTokenCountChange = (value) => {
+    // 确保用户输入的是正整数
+    const count = parseInt(value, 10);
+    if (!isNaN(count) && count > 0) {
+      setTokenCount(count);
+    }
+  };
+
+  // 生成一个随机的四位字母数字字符串
+  const generateRandomSuffix = () => {
+    const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+    let result = '';
+    for (let i = 0; i < 6; i++) {
+      result += characters.charAt(Math.floor(Math.random() * characters.length));
+    }
+    return result;
+  };
 
-    // 新增处理 tokenCount 变化的函数
-    const handleTokenCountChange = (value) => {
-        // 确保用户输入的是正整数
-        const count = parseInt(value, 10);
-        if (!isNaN(count) && count > 0) {
-            setTokenCount(count);
+  const submit = async () => {
+    setLoading(true);
+    if (isEdit) {
+      // 编辑令牌的逻辑保持不变
+      let localInputs = { ...inputs };
+      localInputs.remain_quota = parseInt(localInputs.remain_quota);
+      if (localInputs.expired_time !== -1) {
+        let time = Date.parse(localInputs.expired_time);
+        if (isNaN(time)) {
+          showError('过期时间格式错误!');
+          setLoading(false);
+          return;
+        }
+        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) });
+      const { success, message } = res.data;
+      if (success) {
+        showSuccess('令牌更新成功!');
+        props.refresh();
+        props.handleClose();
+      } else {
+        showError(message);
+      }
+    } else {
+      // 处理新增多个令牌的情况
+      let successCount = 0; // 记录成功创建的令牌数量
+      for (let i = 0; i < tokenCount; i++) {
+        let localInputs = { ...inputs };
+        if (i !== 0) {
+          // 如果用户想要创建多个令牌,则给每个令牌一个序号后缀
+          localInputs.name = `${inputs.name}-${generateRandomSuffix()}`;
         }
-    };
+        localInputs.remain_quota = parseInt(localInputs.remain_quota);
 
-    // 生成一个随机的四位字母数字字符串
-    const generateRandomSuffix = () => {
-        const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
-        let result = '';
-        for (let i = 0; i < 6; i++) {
-            result += characters.charAt(Math.floor(Math.random() * characters.length));
+        if (localInputs.expired_time !== -1) {
+          let time = Date.parse(localInputs.expired_time);
+          if (isNaN(time)) {
+            showError('过期时间格式错误!');
+            setLoading(false);
+            break;
+          }
+          localInputs.expired_time = Math.ceil(time / 1000);
         }
-        return result;
-    };
+        localInputs.model_limits = localInputs.model_limits.join(',');
+        let res = await API.post(`/api/token/`, localInputs);
+        const { success, message } = res.data;
 
-    const submit = async () => {
-        setLoading(true);
-        if (isEdit) {
-            // 编辑令牌的逻辑保持不变
-            let localInputs = {...inputs};
-            localInputs.remain_quota = parseInt(localInputs.remain_quota);
-            if (localInputs.expired_time !== -1) {
-                let time = Date.parse(localInputs.expired_time);
-                if (isNaN(time)) {
-                    showError('过期时间格式错误!');
-                    setLoading(false);
-                    return;
-                }
-                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)});
-            const {success, message} = res.data;
-            if (success) {
-                showSuccess('令牌更新成功!');
-                props.refresh();
-                props.handleClose();
-            } else {
-                showError(message);
-            }
+        if (success) {
+          successCount++;
         } else {
-            // 处理新增多个令牌的情况
-            let successCount = 0; // 记录成功创建的令牌数量
-            for (let i = 0; i < tokenCount; i++) {
-                let localInputs = {...inputs};
-                if (i !== 0) {
-                    // 如果用户想要创建多个令牌,则给每个令牌一个序号后缀
-                    localInputs.name = `${inputs.name}-${generateRandomSuffix()}`;
-                }
-                localInputs.remain_quota = parseInt(localInputs.remain_quota);
-
-                if (localInputs.expired_time !== -1) {
-                    let time = Date.parse(localInputs.expired_time);
-                    if (isNaN(time)) {
-                        showError('过期时间格式错误!');
-                        setLoading(false);
-                        break;
-                    }
-                    localInputs.expired_time = Math.ceil(time / 1000);
-                }
-                localInputs.model_limits = localInputs.model_limits.join(',');
-                let res = await API.post(`/api/token/`, localInputs);
-                const {success, message} = res.data;
-
-                if (success) {
-                    successCount++;
-                } else {
-                    showError(message);
-                    break; // 如果创建失败,终止循环
-                }
-            }
-
-            if (successCount > 0) {
-                showSuccess(`${successCount}个令牌创建成功,请在列表页面点击复制获取令牌!`);
-                props.refresh();
-                props.handleClose();
-            }
+          showError(message);
+          break; // 如果创建失败,终止循环
         }
-        setLoading(false);
-        setInputs(originInputs); // 重置表单
-        setTokenCount(1); // 重置数量为默认值
-    };
+      }
 
+      if (successCount > 0) {
+        showSuccess(`${successCount}个令牌创建成功,请在列表页面点击复制获取令牌!`);
+        props.refresh();
+        props.handleClose();
+      }
+    }
+    setLoading(false);
+    setInputs(originInputs); // 重置表单
+    setTokenCount(1); // 重置数量为默认值
+  };
 
 
-    return (
-        <>
-            <SideSheet
-                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.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>
-                        </Space>
-                    </div>
-                }
-                closeIcon={null}
-                onCancel={() => handleCancel()}
-                width={isMobile() ? '100%' : 600}
-            >
-                <Spin spinning={loading}>
-                    <Input
-                        style={{marginTop: 20}}
-                        label='名称'
-                        name='name'
-                        placeholder={'请输入名称'}
-                        onChange={(value) => handleInputChange('name', value)}
-                        value={name}
-                        autoComplete='new-password'
-                        required={!isEdit}
-                    />
-                    <Divider/>
-                    <DatePicker
-                        label='过期时间'
-                        name='expired_time'
-                        placeholder={'请选择过期时间'}
-                        onChange={(value) => handleInputChange('expired_time', value)}
-                        value={expired_time}
-                        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>
-                        </Space>
-                    </div>
+  return (
+    <>
+      <SideSheet
+        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.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>
+            </Space>
+          </div>
+        }
+        closeIcon={null}
+        onCancel={() => handleCancel()}
+        width={isMobile() ? '100%' : 600}
+      >
+        <Spin spinning={loading}>
+          <Input
+            style={{ marginTop: 20 }}
+            label="名称"
+            name="name"
+            placeholder={'请输入名称'}
+            onChange={(value) => handleInputChange('name', value)}
+            value={name}
+            autoComplete="new-password"
+            required={!isEdit}
+          />
+          <Divider />
+          <DatePicker
+            label="过期时间"
+            name="expired_time"
+            placeholder={'请选择过期时间'}
+            onChange={(value) => handleInputChange('expired_time', value)}
+            value={expired_time}
+            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>
+            </Space>
+          </div>
 
-                    <Divider/>
-                    <Banner type={'warning'}
-                            description={'注意,令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制。'}></Banner>
-                    <div style={{marginTop: 20}}>
-                        <Typography.Text>{`额度${renderQuotaWithPrompt(remain_quota)}`}</Typography.Text>
-                    </div>
-                    <AutoComplete
-                        style={{marginTop: 8}}
-                        name='remain_quota'
-                        placeholder={'请输入额度'}
-                        onChange={(value) => handleInputChange('remain_quota', value)}
-                        value={remain_quota}
-                        autoComplete='new-password'
-                        type='number'
-                        // position={'top'}
-                        data={[
-                            {value: 500000, label: '1$'},
-                            {value: 5000000, label: '10$'},
-                            {value: 25000000, label: '50$'},
-                            {value: 50000000, label: '100$'},
-                            {value: 250000000, label: '500$'},
-                            {value: 500000000, label: '1000$'},
-                        ]}
-                        disabled={unlimited_quota}
-                    />
+          <Divider />
+          <Banner type={'warning'}
+                  description={'注意,令牌的额度仅用于限制令牌本身的最大额度使用量,实际的使用受到账户的剩余额度限制。'}></Banner>
+          <div style={{ marginTop: 20 }}>
+            <Typography.Text>{`额度${renderQuotaWithPrompt(remain_quota)}`}</Typography.Text>
+          </div>
+          <AutoComplete
+            style={{ marginTop: 8 }}
+            name="remain_quota"
+            placeholder={'请输入额度'}
+            onChange={(value) => handleInputChange('remain_quota', value)}
+            value={remain_quota}
+            autoComplete="new-password"
+            type="number"
+            // position={'top'}
+            data={[
+              { value: 500000, label: '1$' },
+              { value: 5000000, label: '10$' },
+              { value: 25000000, label: '50$' },
+              { value: 50000000, label: '100$' },
+              { value: 250000000, label: '500$' },
+              { value: 500000000, label: '1000$' }
+            ]}
+            disabled={unlimited_quota}
+          />
 
-                    {!isEdit && (
-                        <>
-                            <div style={{marginTop: 20}}>
-                                <Typography.Text>新建数量</Typography.Text>
-                            </div>
-                            <AutoComplete
-                                style={{ marginTop: 8 }}
-                                label='数量'
-                                placeholder={'请选择或输入创建令牌的数量'}
-                                onChange={(value) => handleTokenCountChange(value)}
-                                onSelect={(value) => handleTokenCountChange(value)}
-                                value={tokenCount.toString()}
-                                autoComplete='off'
-                                type='number'
-                                data={[
-                                    { value: 10, label: '10个' },
-                                    { value: 20, label: '20个' },
-                                    { value: 30, label: '30个' },
-                                    { value: 100, label: '100个' },
-                                ]}
-                                disabled={unlimited_quota}
-                            />
-                        </>
-                    )}
+          {!isEdit && (
+            <>
+              <div style={{ marginTop: 20 }}>
+                <Typography.Text>新建数量</Typography.Text>
+              </div>
+              <AutoComplete
+                style={{ marginTop: 8 }}
+                label="数量"
+                placeholder={'请选择或输入创建令牌的数量'}
+                onChange={(value) => handleTokenCountChange(value)}
+                onSelect={(value) => handleTokenCountChange(value)}
+                value={tokenCount.toString()}
+                autoComplete="off"
+                type="number"
+                data={[
+                  { value: 10, label: '10个' },
+                  { value: 20, label: '20个' },
+                  { value: 30, label: '30个' },
+                  { value: 100, label: '100个' }
+                ]}
+                disabled={unlimited_quota}
+              />
+            </>
+          )}
 
-                    <div>
-                        <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'
-                                checked={model_limits_enabled}
-                                onChange={(e) => handleInputChange('model_limits_enabled', e.target.checked)}
-                            >
-                            </Checkbox>
-                            <Typography.Text>启用模型限制(非必要,不建议启用)</Typography.Text>
-                        </Space>
-                    </div>
+          <div>
+            <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"
+                checked={model_limits_enabled}
+                onChange={(e) => handleInputChange('model_limits_enabled', e.target.checked)}
+              >
+              </Checkbox>
+              <Typography.Text>启用模型限制(非必要,不建议启用)</Typography.Text>
+            </Space>
+          </div>
 
-                    <Select
-                        style={{marginTop: 8}}
-                        placeholder={'请选择该渠道所支持的模型'}
-                        name='models'
-                        required
-                        multiple
-                        selection
-                        onChange={value => {
-                            handleInputChange('model_limits', value);
-                        }}
-                        value={inputs.model_limits}
-                        autoComplete='new-password'
-                        optionList={models}
-                        disabled={!model_limits_enabled}
-                    />
-                </Spin>
-            </SideSheet>
-        </>
-    );
+          <Select
+            style={{ marginTop: 8 }}
+            placeholder={'请选择该渠道所支持的模型'}
+            name="models"
+            required
+            multiple
+            selection
+            onChange={value => {
+              handleInputChange('model_limits', value);
+            }}
+            value={inputs.model_limits}
+            autoComplete="new-password"
+            optionList={models}
+            disabled={!model_limits_enabled}
+          />
+        </Spin>
+      </SideSheet>
+    </>
+  );
 };
 
 export default EditToken;

+ 89 - 89
web/src/pages/User/AddUser.js

@@ -1,98 +1,98 @@
-import React, {useState} from 'react';
-import {API, isMobile, showError, showSuccess} from '../../helpers';
-import Title from "@douyinfe/semi-ui/lib/es/typography/title";
-import {Button, SideSheet, Space, Input, Spin} from "@douyinfe/semi-ui";
+import React, { useState } from 'react';
+import { API, isMobile, showError, showSuccess } from '../../helpers';
+import Title from '@douyinfe/semi-ui/lib/es/typography/title';
+import { Button, Input, SideSheet, Space, Spin } from '@douyinfe/semi-ui';
 
 const AddUser = (props) => {
-    const originInputs = {
-        username: '',
-        display_name: '',
-        password: '',
-    };
-    const [inputs, setInputs] = useState(originInputs);
-    const [loading, setLoading] = useState(false);
-    const {username, display_name, password} = inputs;
+  const originInputs = {
+    username: '',
+    display_name: '',
+    password: ''
+  };
+  const [inputs, setInputs] = useState(originInputs);
+  const [loading, setLoading] = useState(false);
+  const { username, display_name, password } = inputs;
 
-    const handleInputChange = (name, value) => {
-        setInputs((inputs) => ({...inputs, [name]: value}));
-    };
+  const handleInputChange = (name, value) => {
+    setInputs((inputs) => ({ ...inputs, [name]: value }));
+  };
 
-    const submit = async () => {
-        setLoading(true);
-        if (inputs.username === '' || inputs.password === '') return;
-        const res = await API.post(`/api/user/`, inputs);
-        const {success, message} = res.data;
-        if (success) {
-            showSuccess('用户账户创建成功!');
-            setInputs(originInputs);
-            props.refresh();
-            props.handleClose();
-        } else {
-            showError(message);
-        }
-        setLoading(false);
-    };
-
-    const handleCancel = () => {
-        props.handleClose();
+  const submit = async () => {
+    setLoading(true);
+    if (inputs.username === '' || inputs.password === '') return;
+    const res = await API.post(`/api/user/`, inputs);
+    const { success, message } = res.data;
+    if (success) {
+      showSuccess('用户账户创建成功!');
+      setInputs(originInputs);
+      props.refresh();
+      props.handleClose();
+    } else {
+      showError(message);
     }
+    setLoading(false);
+  };
 
-    return (
-        <>
-            <SideSheet
-                placement={'left'}
-                title={<Title level={3}>{'添加用户'}</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}>
-                    <Input
-                        style={{marginTop: 20}}
-                        label="用户名"
-                        name="username"
-                        addonBefore={'用户名'}
-                        placeholder={'请输入用户名'}
-                        onChange={value => handleInputChange('username', value)}
-                        value={username}
-                        autoComplete="off"
-                    />
-                    <Input
-                        style={{marginTop: 20}}
-                        addonBefore={'显示名'}
-                        label="显示名称"
-                        name="display_name"
-                        autoComplete="off"
-                        placeholder={'请输入显示名称'}
-                        onChange={value => handleInputChange('display_name', value)}
-                        value={display_name}
-                    />
-                    <Input
-                        style={{marginTop: 20}}
-                        label="密 码"
-                        name="password"
-                        type={'password'}
-                        addonBefore={'密码'}
-                        placeholder={'请输入密码'}
-                        onChange={value => handleInputChange('password', value)}
-                        value={password}
-                        autoComplete="off"
-                    />
-                </Spin>
-            </SideSheet>
-        </>
-    );
+  const handleCancel = () => {
+    props.handleClose();
+  };
+
+  return (
+    <>
+      <SideSheet
+        placement={'left'}
+        title={<Title level={3}>{'添加用户'}</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}>
+          <Input
+            style={{ marginTop: 20 }}
+            label="用户名"
+            name="username"
+            addonBefore={'用户名'}
+            placeholder={'请输入用户名'}
+            onChange={value => handleInputChange('username', value)}
+            value={username}
+            autoComplete="off"
+          />
+          <Input
+            style={{ marginTop: 20 }}
+            addonBefore={'显示名'}
+            label="显示名称"
+            name="display_name"
+            autoComplete="off"
+            placeholder={'请输入显示名称'}
+            onChange={value => handleInputChange('display_name', value)}
+            value={display_name}
+          />
+          <Input
+            style={{ marginTop: 20 }}
+            label="密 码"
+            name="password"
+            type={'password'}
+            addonBefore={'密码'}
+            placeholder={'请输入密码'}
+            onChange={value => handleInputChange('password', value)}
+            value={password}
+            autoComplete="off"
+          />
+        </Spin>
+      </SideSheet>
+    </>
+  );
 };
 
 export default AddUser;

+ 33 - 33
web/src/pages/User/EditUser.js

@@ -1,9 +1,9 @@
 import React, { useEffect, useState } from 'react';
-import { useParams, useNavigate } from 'react-router-dom';
+import { useNavigate } from 'react-router-dom';
 import { API, isMobile, showError, showSuccess } from '../../helpers';
-import { renderQuota, renderQuotaWithPrompt } from '../../helpers/render';
-import Title from "@douyinfe/semi-ui/lib/es/typography/title";
-import { SideSheet, Space, Button, Spin, Input, Typography, Select, Divider } from "@douyinfe/semi-ui";
+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';
 
 const EditUser = (props) => {
   const userId = props.editingUser.id;
@@ -29,7 +29,7 @@ const EditUser = (props) => {
       let res = await API.get(`/api/group/`);
       setGroupOptions(res.data.data.map((group) => ({
         label: group,
-        value: group,
+        value: group
       })));
     } catch (error) {
       showError(error.message);
@@ -38,7 +38,7 @@ const EditUser = (props) => {
   const navigate = useNavigate();
   const handleCancel = () => {
     props.handleClose();
-  }
+  };
   const loadUser = async () => {
     setLoading(true);
     let res = undefined;
@@ -98,8 +98,8 @@ 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,35 +112,35 @@ const EditUser = (props) => {
             <Typography.Text>用户名</Typography.Text>
           </div>
           <Input
-            label='用户名'
-            name='username'
+            label="用户名"
+            name="username"
             placeholder={'请输入新的用户名'}
             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)}
             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)}
             value={display_name}
-            autoComplete='new-password'
+            autoComplete="new-password"
           />
           {
             userId && <>
@@ -149,7 +149,7 @@ const EditUser = (props) => {
               </div>
               <Select
                 placeholder={'请选择分组'}
-                name='group'
+                name="group"
                 fluid
                 search
                 selection
@@ -157,19 +157,19 @@ const EditUser = (props) => {
                 additionLabel={'请在系统设置页面编辑分组倍率以添加新的分组:'}
                 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)}
                 value={quota}
                 type={'number'}
-                autoComplete='new-password'
+                autoComplete="new-password"
               />
             </>
           }
@@ -178,37 +178,37 @@ const EditUser = (props) => {
             <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>