diff --git a/Dockerfile b/Dockerfile
index ec2f9d43..6743b139 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -12,6 +12,10 @@ WORKDIR /web/berry
RUN npm install
RUN DISABLE_ESLINT_PLUGIN='true' REACT_APP_VERSION=$(cat VERSION) npm run build
+WORKDIR /web/air
+RUN npm install
+RUN DISABLE_ESLINT_PLUGIN='true' REACT_APP_VERSION=$(cat VERSION) npm run build
+
FROM golang AS builder2
ENV GO111MODULE=on \
diff --git a/common/config/config.go b/common/config/config.go
index 83cfa933..a261523d 100644
--- a/common/config/config.go
+++ b/common/config/config.go
@@ -107,6 +107,7 @@ var Theme = env.String("THEME", "default")
var ValidThemes = map[string]bool{
"default": true,
"berry": true,
+ "air": true,
}
// All duration's unit is seconds
diff --git a/web/README.md b/web/README.md
index 86486085..59d91424 100644
--- a/web/README.md
+++ b/web/README.md
@@ -33,6 +33,12 @@
|||
|||
+### 主题:air
+由 [Calon](https://github.com/Calcium-Ion) 开发。
+|||
+|:---:|:---:|
+
+
#### 开发说明
请查看 [web/berry/README.md](https://github.com/songquanpeng/one-api/tree/main/web/berry/README.md)
diff --git a/web/THEMES b/web/THEMES
index 6b0157cb..149e8698 100644
--- a/web/THEMES
+++ b/web/THEMES
@@ -1,2 +1,3 @@
default
berry
+air
diff --git a/web/air/.gitignore b/web/air/.gitignore
new file mode 100644
index 00000000..2b5bba76
--- /dev/null
+++ b/web/air/.gitignore
@@ -0,0 +1,26 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+
+# testing
+/coverage
+
+# production
+/build
+
+# misc
+.DS_Store
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.idea
+package-lock.json
+yarn.lock
\ No newline at end of file
diff --git a/web/air/README.md b/web/air/README.md
new file mode 100644
index 00000000..1b1031a3
--- /dev/null
+++ b/web/air/README.md
@@ -0,0 +1,21 @@
+# React Template
+
+## Basic Usages
+
+```shell
+# Runs the app in the development mode
+npm start
+
+# Builds the app for production to the `build` folder
+npm run build
+```
+
+If you want to change the default server, please set `REACT_APP_SERVER` environment variables before build,
+for example: `REACT_APP_SERVER=http://your.domain.com`.
+
+Before you start editing, make sure your `Actions on Save` options have `Optimize imports` & `Run Prettier` enabled.
+
+## Reference
+
+1. https://github.com/OIerDb-ng/OIerDb
+2. https://github.com/cornflourblue/react-hooks-redux-registration-login-example
\ No newline at end of file
diff --git a/web/air/package.json b/web/air/package.json
new file mode 100644
index 00000000..3bdf3952
--- /dev/null
+++ b/web/air/package.json
@@ -0,0 +1,60 @@
+{
+ "name": "react-template",
+ "version": "0.1.0",
+ "private": true,
+ "dependencies": {
+ "@douyinfe/semi-icons": "^2.46.1",
+ "@douyinfe/semi-ui": "^2.46.1",
+ "@visactor/react-vchart": "~1.8.8",
+ "@visactor/vchart": "~1.8.8",
+ "@visactor/vchart-semi-theme": "~1.8.8",
+ "axios": "^0.27.2",
+ "history": "^5.3.0",
+ "marked": "^4.1.1",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-dropzone": "^14.2.3",
+ "react-fireworks": "^1.0.4",
+ "react-router-dom": "^6.3.0",
+ "react-scripts": "5.0.1",
+ "react-telegram-login": "^1.1.2",
+ "react-toastify": "^9.0.8",
+ "react-turnstile": "^1.0.5",
+ "semantic-ui-css": "^2.5.0",
+ "semantic-ui-react": "^2.1.3",
+ "usehooks-ts": "^2.9.1"
+ },
+ "scripts": {
+ "start": "react-scripts start",
+ "build": "react-scripts build && mv -f build ../build/air",
+ "test": "react-scripts test",
+ "eject": "react-scripts eject"
+ },
+ "eslintConfig": {
+ "extends": [
+ "react-app",
+ "react-app/jest"
+ ]
+ },
+ "browserslist": {
+ "production": [
+ ">0.2%",
+ "not dead",
+ "not op_mini all"
+ ],
+ "development": [
+ "last 1 chrome version",
+ "last 1 firefox version",
+ "last 1 safari version"
+ ]
+ },
+ "devDependencies": {
+ "prettier": "2.8.8",
+ "typescript": "4.4.2"
+ },
+ "prettier": {
+ "singleQuote": true,
+ "jsxSingleQuote": true
+ },
+ "proxy": "http://localhost:3000"
+}
diff --git a/web/air/public/favicon.ico b/web/air/public/favicon.ico
new file mode 100644
index 00000000..c2c8de0c
Binary files /dev/null and b/web/air/public/favicon.ico differ
diff --git a/web/air/public/index.html b/web/air/public/index.html
new file mode 100644
index 00000000..e9697b92
--- /dev/null
+++ b/web/air/public/index.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+ New API
+
+
+
+
+
+
diff --git a/web/air/public/logo.png b/web/air/public/logo.png
new file mode 100644
index 00000000..0f237a22
Binary files /dev/null and b/web/air/public/logo.png differ
diff --git a/web/air/public/robots.txt b/web/air/public/robots.txt
new file mode 100644
index 00000000..e9e57dc4
--- /dev/null
+++ b/web/air/public/robots.txt
@@ -0,0 +1,3 @@
+# https://www.robotstxt.org/robotstxt.html
+User-agent: *
+Disallow:
diff --git a/web/air/src/App.js b/web/air/src/App.js
new file mode 100644
index 00000000..5a673187
--- /dev/null
+++ b/web/air/src/App.js
@@ -0,0 +1,242 @@
+import React, { lazy, Suspense, useContext, useEffect } from 'react';
+import { Route, Routes } from 'react-router-dom';
+import Loading from './components/Loading';
+import User from './pages/User';
+import { PrivateRoute } from './components/PrivateRoute';
+import RegisterForm from './components/RegisterForm';
+import LoginForm from './components/LoginForm';
+import NotFound from './pages/NotFound';
+import Setting from './pages/Setting';
+import EditUser from './pages/User/EditUser';
+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 Channel from './pages/Channel';
+import Token from './pages/Token';
+import EditChannel from './pages/Channel/EditChannel';
+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';
+
+const Home = lazy(() => import('./pages/Home'));
+const About = lazy(() => import('./pages/About'));
+
+function App() {
+ const [userState, userDispatch] = useContext(UserContext);
+ // const [statusState, statusDispatch] = useContext(StatusContext);
+
+ const loadUser = () => {
+ let user = localStorage.getItem('user');
+ if (user) {
+ let data = JSON.parse(user);
+ userDispatch({ type: 'login', payload: data });
+ }
+ };
+
+ useEffect(() => {
+ loadUser();
+ let systemName = getSystemName();
+ if (systemName) {
+ document.title = systemName;
+ }
+ let logo = getLogo();
+ if (logo) {
+ let linkElement = document.querySelector('link[rel~=\'icon\']');
+ if (linkElement) {
+ linkElement.href = logo;
+ }
+ }
+ }, []);
+
+ return (
+
+
+
+ }>
+
+
+ }
+ />
+
+
+
+ }
+ />
+ }>
+
+
+ }
+ />
+ }>
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+ }>
+
+
+ }
+ />
+ }>
+
+
+ }
+ />
+ }>
+
+
+ }
+ />
+ }>
+
+
+ }
+ />
+ }>
+
+
+ }
+ />
+ }>
+
+
+ }
+ />
+ }>
+
+
+ }
+ />
+
+ }>
+
+
+
+ }
+ />
+
+ }>
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+ }>
+
+
+ }
+ />
+ }>
+
+
+ }
+ />
+
+ } />
+
+
+
+ );
+}
+
+export default App;
diff --git a/web/air/src/components/ChannelsTable.js b/web/air/src/components/ChannelsTable.js
new file mode 100644
index 00000000..dee21a01
--- /dev/null
+++ b/web/air/src/components/ChannelsTable.js
@@ -0,0 +1,738 @@
+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 {
+ Button,
+ Dropdown,
+ Form,
+ InputNumber,
+ Popconfirm,
+ Space,
+ SplitButtonGroup,
+ Switch,
+ Table,
+ Tag,
+ Tooltip,
+ Typography
+} from '@douyinfe/semi-ui';
+import EditChannel from '../pages/Channel/EditChannel';
+import { IconTreeTriangleDown } from '@douyinfe/semi-icons';
+
+function renderTimestamp(timestamp) {
+ return (
+ <>
+ {timestamp2string(timestamp)}
+ >
+ );
+}
+
+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' };
+ }
+ return {type2label[type]?.text};
+}
+
+const ChannelsTable = () => {
+ const columns = [
+ // {
+ // title: '',
+ // dataIndex: 'checkbox',
+ // className: 'checkbox',
+ // },
+ {
+ title: 'ID',
+ dataIndex: 'id'
+ },
+ {
+ title: '名称',
+ dataIndex: 'name'
+ },
+ // {
+ // title: '分组',
+ // dataIndex: 'group',
+ // render: (text, record, index) => {
+ // return (
+ //
+ //
+ // {
+ // text.split(',').map((item, index) => {
+ // return (renderGroup(item));
+ // })
+ // }
+ //
+ //
+ // );
+ // }
+ // },
+ {
+ title: '类型',
+ dataIndex: 'type',
+ render: (text, record, index) => {
+ return (
+
+ {renderType(text)}
+
+ );
+ }
+ },
+ {
+ title: '状态',
+ dataIndex: 'status',
+ render: (text, record, index) => {
+ return (
+
+ {renderStatus(text)}
+
+ );
+ }
+ },
+ {
+ title: '响应时间',
+ dataIndex: 'response_time',
+ render: (text, record, index) => {
+ return (
+
+ {renderResponseTime(text)}
+
+ );
+ }
+ },
+ {
+ title: '已用/剩余',
+ dataIndex: 'expired_time',
+ render: (text, record, index) => {
+ return (
+
+
+
+ {renderQuota(record.used_quota)}
+
+
+ {
+ updateChannelBalance(record);
+ }}>${renderNumberWithPoint(record.balance)}
+
+
+
+ );
+ }
+ },
+ {
+ title: '优先级',
+ dataIndex: 'priority',
+ render: (text, record, index) => {
+ return (
+
+ {
+ manageChannel(record.id, 'priority', record, e.target.value);
+ }}
+ keepFocus={true}
+ innerButtons
+ defaultValue={record.priority}
+ min={-999}
+ />
+
+ );
+ }
+ },
+ // {
+ // title: '权重',
+ // dataIndex: 'weight',
+ // render: (text, record, index) => {
+ // return (
+ //
+ // {
+ // manageChannel(record.id, 'weight', record, e.target.value);
+ // }}
+ // keepFocus={true}
+ // innerButtons
+ // defaultValue={record.weight}
+ // min={0}
+ // />
+ //
+ // );
+ // }
+ // },
+ {
+ title: '',
+ dataIndex: 'operate',
+ render: (text, record, index) => (
+
+ {/*
+
+
+ }>
+
+ */}
+
+
{
+ manageChannel(record.id, 'delete', record).then(
+ () => {
+ removeRecord(record.id);
+ }
+ );
+ }}
+ >
+
+
+ {
+ record.status === 1 ?
+
:
+
+ }
+
+
+ )
+ }
+ ];
+
+ 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;
+ }
+ data.priority = parseInt(value);
+ res = await API.put('/api/channel/', data);
+ break;
+ case 'weight':
+ if (value === '') {
+ return;
+ }
+ data.weight = parseInt(value);
+ if (data.weight < 0) {
+ data.weight = 0;
+ }
+ 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;
+ }
+ setChannels(newChannels);
+ } else {
+ showError(message);
+ }
+ };
+
+ const renderStatus = (status) => {
+ switch (status) {
+ case 1:
+ return 已启用;
+ case 2:
+ return (
+
+ 已禁用
+
+ );
+ case 3:
+ return (
+
+ 自动禁用
+
+ );
+ default:
+ return (
+
+ 未知状态
+
+ );
+ }
+ };
+
+ const renderResponseTime = (responseTime) => {
+ let time = responseTime / 1000;
+ time = time.toFixed(2) + ' 秒';
+ if (responseTime === 0) {
+ return 未测试;
+ } else if (responseTime <= 1000) {
+ return {time};
+ } else if (responseTime <= 3000) {
+ return {time};
+ } else if (responseTime <= 5000) {
+ return {time};
+ } else {
+ return {time};
+ }
+ };
+
+ 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 testChannels = async (scope) => {
+ const res = await API.get(`/api/channel/test?scope=${scope}`);
+ 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);
+ };
+
+ 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);
+ }
+ };
+
+ let pageData = channels.slice((activePage - 1) * pageSize, activePage * pageSize);
+
+ 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 {};
+ }
+ };
+
+
+ return (
+ <>
+
+
+
+
+
+
+ { testChannels("all") }}
+ position={isMobile() ? 'top' : 'left'}
+ >
+
+
+ { testChannels("disabled") }}
+ position={isMobile() ? 'top' : 'left'}
+ >
+
+
+ {/*
+
+ */}
+
+
+
+
+
+
+ {/*
*/}
+
+ {/*
*/}
+
+ {/*
+
+ 开启批量删除
+ {
+ setEnableBatchDelete(v);
+ }}>
+
+
+
+
+
+
+
+
+
+
+
+ 使用ID排序
+ {
+ localStorage.setItem('id-sort', v + '');
+ setIdSort(v);
+ loadChannels(0, pageSize, v)
+ .then()
+ .catch((reason) => {
+ showError(reason);
+ });
+ }}>
+
+
+
*/}
+
+ '',
+ 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
+ } />
+ >
+ );
+};
+
+export default ChannelsTable;
diff --git a/web/air/src/components/Footer.js b/web/air/src/components/Footer.js
new file mode 100644
index 00000000..6fd0fa54
--- /dev/null
+++ b/web/air/src/components/Footer.js
@@ -0,0 +1,64 @@
+import React, { useEffect, useState } from 'react';
+
+import { Container, Segment } from 'semantic-ui-react';
+import { getFooterHTML, getSystemName } from '../helpers';
+
+const Footer = () => {
+ const systemName = getSystemName();
+ const [footer, setFooter] = useState(getFooterHTML());
+ let remainCheckTimes = 5;
+
+ const loadFooter = () => {
+ let footer_html = localStorage.getItem('footer_html');
+ if (footer_html) {
+ setFooter(footer_html);
+ }
+ };
+
+ useEffect(() => {
+ const timer = setInterval(() => {
+ if (remainCheckTimes <= 0) {
+ clearInterval(timer);
+ return;
+ }
+ remainCheckTimes--;
+ loadFooter();
+ }, 200);
+ return () => clearTimeout(timer);
+ }, []);
+
+ return (
+
+
+ {footer ? (
+
+ ) : (
+
+ )}
+
+
+ );
+};
+
+export default Footer;
diff --git a/web/air/src/components/GitHubOAuth.js b/web/air/src/components/GitHubOAuth.js
new file mode 100644
index 00000000..4e3b93ba
--- /dev/null
+++ b/web/air/src/components/GitHubOAuth.js
@@ -0,0 +1,58 @@
+import React, { useContext, useEffect, useState } from 'react';
+import { Dimmer, Loader, Segment } from 'semantic-ui-react';
+import { useNavigate, useSearchParams } from 'react-router-dom';
+import { API, showError, showSuccess } from '../helpers';
+import { UserContext } from '../context/User';
+
+const GitHubOAuth = () => {
+ const [searchParams, setSearchParams] = useSearchParams();
+
+ const [userState, userDispatch] = useContext(UserContext);
+ const [prompt, setPrompt] = useState('处理中...');
+ const [processing, setProcessing] = useState(true);
+
+ let navigate = useNavigate();
+
+ const sendCode = async (code, state, count) => {
+ const res = await API.get(`/api/oauth/github?code=${code}&state=${state}`);
+ const { success, message, data } = res.data;
+ if (success) {
+ if (message === 'bind') {
+ showSuccess('绑定成功!');
+ navigate('/setting');
+ } else {
+ userDispatch({ type: 'login', payload: data });
+ localStorage.setItem('user', JSON.stringify(data));
+ showSuccess('登录成功!');
+ navigate('/');
+ }
+ } else {
+ showError(message);
+ if (count === 0) {
+ setPrompt(`操作失败,重定向至登录界面中...`);
+ navigate('/setting'); // in case this is failed to bind GitHub
+ return;
+ }
+ count++;
+ setPrompt(`出现错误,第 ${count} 次重试中...`);
+ await new Promise((resolve) => setTimeout(resolve, count * 2000));
+ await sendCode(code, state, count);
+ }
+ };
+
+ useEffect(() => {
+ let code = searchParams.get('code');
+ let state = searchParams.get('state');
+ sendCode(code, state, 0).then();
+ }, []);
+
+ return (
+
+
+ {prompt}
+
+
+ );
+};
+
+export default GitHubOAuth;
diff --git a/web/air/src/components/HeaderBar.js b/web/air/src/components/HeaderBar.js
new file mode 100644
index 00000000..eaf36c48
--- /dev/null
+++ b/web/air/src/components/HeaderBar.js
@@ -0,0 +1,161 @@
+import React, { useContext, useEffect, useState } from 'react';
+import { Link, useNavigate } from 'react-router-dom';
+import { UserContext } from '../context/User';
+
+import { API, getLogo, getSystemName, showSuccess } from '../helpers';
+import '../index.css';
+
+import fireworks from 'react-fireworks';
+
+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:
+ }
+];
+
+if (localStorage.getItem('chat_link')) {
+ headerButtons.splice(1, 0, {
+ name: '聊天',
+ to: '/chat',
+ icon: 'comments'
+ });
+}
+
+const HeaderBar = () => {
+ 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);
+
+ 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);
+ };
+
+ 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 (
+ <>
+
+
+
+
+
+ >
+ );
+};
+
+export default HeaderBar;
diff --git a/web/air/src/components/Loading.js b/web/air/src/components/Loading.js
new file mode 100644
index 00000000..bacb53b3
--- /dev/null
+++ b/web/air/src/components/Loading.js
@@ -0,0 +1,14 @@
+import React from 'react';
+import { Dimmer, Loader, Segment } from 'semantic-ui-react';
+
+const Loading = ({ prompt: name = 'page' }) => {
+ return (
+
+
+ 加载{name}中...
+
+
+ );
+};
+
+export default Loading;
diff --git a/web/air/src/components/LoginForm.js b/web/air/src/components/LoginForm.js
new file mode 100644
index 00000000..3cbeb52c
--- /dev/null
+++ b/web/air/src/components/LoginForm.js
@@ -0,0 +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, showError, showInfo, showSuccess } from '../helpers';
+import { onGitHubOAuthClicked } from './utils';
+import Turnstile from 'react-turnstile';
+import { Button, Card, Divider, Form, Icon, Layout, Modal } from '@douyinfe/semi-ui';
+import 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();
+
+ 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);
+
+ const onWeChatLoginClicked = () => {
+ setShowWeChatLoginModal(true);
+ };
+
+ 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);
+ }
+ };
+
+ 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);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+ 用户登录
+
+ handleChange('username', value)}
+ />
+ handleChange('password', value)}
+ />
+
+
+
+
+
+ 没有账号请先 注册账号
+
+
+ 忘记密码 点击重置
+
+
+ {status.github_oauth || status.wechat_login || status.telegram_oauth ? (
+ <>
+
+ 第三方登录
+
+
+ {status.github_oauth ? (
+ }
+ onClick={() => onGitHubOAuthClicked(status.github_client_id)}
+ />
+ ) : (
+ <>>
+ )}
+ {status.wechat_login ? (
+ } />}
+ onClick={onWeChatLoginClicked}
+ />
+ ) : (
+ <>>
+ )}
+
+ {status.telegram_oauth ? (
+
+ ) : (
+ <>>
+ )}
+
+ >
+ ) : (
+ <>>
+ )}
+ setShowWeChatLoginModal(false)}
+ okText={'登录'}
+ size={'small'}
+ centered={true}
+ >
+
+

+
+
+
+ 微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)
+
+
+ handleChange('wechat_verification_code', value)}
+ />
+
+
+
+ {turnstileEnabled ? (
+
+ {
+ setTurnstileToken(token);
+ }}
+ />
+
+ ) : (
+ <>>
+ )}
+
+
+
+
+
+
+ );
+};
+
+export default LoginForm;
diff --git a/web/air/src/components/LogsTable.js b/web/air/src/components/LogsTable.js
new file mode 100644
index 00000000..004188c3
--- /dev/null
+++ b/web/air/src/components/LogsTable.js
@@ -0,0 +1,401 @@
+import React, { useEffect, useState } from 'react';
+import { API, copy, isAdmin, showError, showSuccess, timestamp2string } from '../helpers';
+
+import { Avatar, Button, Form, Layout, Modal, Select, Space, Spin, Table, Tag } from '@douyinfe/semi-ui';
+import { 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;
+
+function renderTimestamp(timestamp) {
+ return (<>
+ {timestamp2string(timestamp)}
+ >);
+}
+
+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'];
+
+function renderType(type) {
+ switch (type) {
+ case 1:
+ return 充值 ;
+ case 2:
+ return 消费 ;
+ case 3:
+ return 管理 ;
+ case 4:
+ return 系统 ;
+ default:
+ return 未知 ;
+ }
+}
+
+function renderIsStream(bool) {
+ if (bool) {
+ return 流;
+ } else {
+ return 非流;
+ }
+}
+
+function renderUseTime(type) {
+ const time = parseInt(type);
+ if (time < 101) {
+ return {time} s ;
+ } else if (time < 300) {
+ return {time} s ;
+ } else {
+ return {time} s ;
+ }
+}
+
+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 ?
+ { {text} }
+
: <>> : <>>);
+ }
+ }, {
+ title: '用户',
+ dataIndex: 'username',
+ className: isAdmin() ? 'tableShow' : 'tableHiddle',
+ render: (text, record, index) => {
+ return (isAdminUser ?
+
showUserInfo(record.user_id)}>
+ {typeof text === 'string' && text.slice(0, 1)}
+
+ {text}
+
: <>>);
+ }
+ }, {
+ title: '令牌', dataIndex: 'token_name', render: (text, record, index) => {
+ return (record.type === 0 || record.type === 2 ?
+ {
+ copyText(text);
+ }}> {text}
+
: <>>);
+ }
+ }, {
+ title: '类型', dataIndex: 'type', render: (text, record, index) => {
+ return (
+ {renderType(text)}
+
);
+ }
+ }, {
+ title: '模型', dataIndex: 'model_name', render: (text, record, index) => {
+ return (record.type === 0 || record.type === 2 ?
+ {
+ copyText(text);
+ }}> {text}
+
: <>>);
+ }
+ },
+ // {
+ // title: '用时', dataIndex: 'use_time', render: (text, record, index) => {
+ // return (
+ //
+ // {renderUseTime(text)}
+ // {renderIsStream(record.is_stream)}
+ //
+ //
);
+ // }
+ // },
+ {
+ title: '提示', dataIndex: 'prompt_tokens', render: (text, record, index) => {
+ return (record.type === 0 || record.type === 2 ?
+ { {text} }
+
: <>>);
+ }
+ }, {
+ title: '补全', dataIndex: 'completion_tokens', render: (text, record, index) => {
+ return (parseInt(text) > 0 && (record.type === 0 || record.type === 2) ?
+ { {text} }
+
: <>>);
+ }
+ }, {
+ title: '花费', dataIndex: 'quota', render: (text, record, index) => {
+ return (record.type === 0 || record.type === 2 ?
+ {renderQuota(text, 6)}
+
: <>>);
+ }
+ }, {
+ title: '详情', dataIndex: 'content', render: (text, record, index) => {
+ return
+ {text}
+ ;
+ }
+ }];
+
+ 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 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 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 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:
+
用户名: {data.username}
+
余额: {renderQuota(data.quota)}
+
已用额度:{renderQuota(data.used_quota)}
+
请求次数:{renderNumber(data.request_count)}
+
, 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 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);
+ };
+
+ 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 handlePageSizeChange = async (size) => {
+ localStorage.setItem('page-size', size + '');
+ setPageSize(size);
+ setActivePage(1);
+ loadLogs(0, size)
+ .then()
+ .catch((reason) => {
+ showError(reason);
+ });
+ };
+
+ 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 });
+ }
+ };
+
+ 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);
+ };
+
+ return (<>
+
+
+
+ 使用明细(总消耗额度:
+ {showStat ? renderQuota(stat.quota) : '点击查看'}
+ )
+
+
+
+ handleInputChange(value, 'token_name')} />
+ handleInputChange(value, 'model_name')} />
+ handleInputChange(value, 'start_timestamp')} />
+ handleInputChange(value, 'end_timestamp')} />
+ {isAdminUser && <>
+ handleInputChange(value, 'channel')} />
+ handleInputChange(value, 'username')} />
+ >}
+
+
+
+ >
+
+ {
+ handlePageSizeChange(size).then();
+ },
+ onPageChange: handlePageChange
+ }} />
+
+
+ >);
+};
+
+export default LogsTable;
diff --git a/web/air/src/components/MjLogsTable.js b/web/air/src/components/MjLogsTable.js
new file mode 100644
index 00000000..6a6fbd95
--- /dev/null
+++ b/web/air/src/components/MjLogsTable.js
@@ -0,0 +1,454 @@
+import React, { useEffect, useState } from 'react';
+import { API, copy, isAdmin, showError, showSuccess, timestamp2string } from '../helpers';
+
+import { Banner, Button, Form, ImagePreview, Layout, Modal, Progress, Table, Tag, Typography } from '@douyinfe/semi-ui';
+import { ITEMS_PER_PAGE } from '../constants';
+
+
+const colors = ['amber', 'blue', 'cyan', 'green', 'grey', 'indigo',
+ 'light-blue', 'lime', 'orange', 'pink',
+ 'purple', 'red', 'teal', 'violet', 'yellow'
+];
+
+function renderType(type) {
+ switch (type) {
+ case 'IMAGINE':
+ return 绘图;
+ case 'UPSCALE':
+ return 放大;
+ case 'VARIATION':
+ return 变换;
+ case 'HIGH_VARIATION':
+ return 强变换;
+ case 'LOW_VARIATION':
+ return 弱变换;
+ case 'PAN':
+ return 平移;
+ case 'DESCRIBE':
+ return 图生文;
+ case 'BLEND':
+ return 图混合;
+ case 'SHORTEN':
+ return 缩词;
+ case 'REROLL':
+ return 重绘;
+ case 'INPAINT':
+ return 局部重绘-提交;
+ case 'ZOOM':
+ return 变焦;
+ case 'CUSTOM_ZOOM':
+ return 自定义变焦-提交;
+ case 'MODAL':
+ return 窗口处理;
+ case 'SWAP_FACE':
+ return 换脸;
+ default:
+ return 未知;
+ }
+}
+
+
+function renderCode(code) {
+ switch (code) {
+ case 1:
+ return 已提交;
+ case 21:
+ return 等待中;
+ case 22:
+ return 重复提交;
+ case 0:
+ return 未提交;
+ default:
+ return 未知;
+ }
+}
+
+
+function renderStatus(type) {
+ // Ensure all cases are string literals by adding quotes.
+ switch (type) {
+ case 'SUCCESS':
+ return 成功;
+ case 'NOT_START':
+ return 未启动;
+ case 'SUBMITTED':
+ return 队列中;
+ case 'IN_PROGRESS':
+ return 执行中;
+ case 'FAILURE':
+ return 失败;
+ case 'MODAL':
+ return 窗口等待;
+ default:
+ return 未知;
+ }
+}
+
+const renderTimestamp = (timestampInSeconds) => {
+ 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); // 获取秒钟,并保证两位数
+
+ 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 (
+
+ {renderTimestamp(text / 1000)}
+
+ );
+ }
+ },
+ {
+ title: '渠道',
+ dataIndex: 'channel_id',
+ className: isAdmin() ? 'tableShow' : 'tableHiddle',
+ render: (text, record, index) => {
+ return (
+
+
+ {
+ copyText(text); // 假设copyText是用于文本复制的函数
+ }}> {text}
+
+
+ );
+ }
+ },
+ {
+ title: '类型',
+ dataIndex: 'action',
+ render: (text, record, index) => {
+ return (
+
+ {renderType(text)}
+
+ );
+ }
+ },
+ {
+ title: '任务ID',
+ dataIndex: 'mj_id',
+ render: (text, record, index) => {
+ return (
+
+ {text}
+
+ );
+ }
+ },
+ {
+ title: '提交结果',
+ dataIndex: 'code',
+ className: isAdmin() ? 'tableShow' : 'tableHiddle',
+ render: (text, record, index) => {
+ return (
+
+ {renderCode(text)}
+
+ );
+ }
+ },
+ {
+ title: '任务状态',
+ dataIndex: 'status',
+ className: isAdmin() ? 'tableShow' : 'tableHiddle',
+ render: (text, record, index) => {
+ return (
+
+ {renderStatus(text)}
+
+ );
+ }
+ },
+ {
+ title: '进度',
+ dataIndex: 'progress',
+ render: (text, record, index) => {
+ return (
+
+ {
+ // 转换例如100%为数字100,如果text未定义,返回0
+
+ }
+
+ );
+ }
+ },
+ {
+ title: '结果图片',
+ dataIndex: 'image_url',
+ render: (text, record, index) => {
+ if (!text) {
+ return '无';
+ }
+ return (
+
+ );
+ }
+ },
+ {
+ title: 'Prompt',
+ dataIndex: 'prompt',
+ render: (text, record, index) => {
+ // 如果text未定义,返回替代文本,例如空字符串''或其他
+ if (!text) {
+ return '无';
+ }
+
+ return (
+ {
+ setModalContent(text);
+ setIsModalOpen(true);
+ }}
+ >
+ {text}
+
+ );
+ }
+ },
+ {
+ title: 'PromptEn',
+ dataIndex: 'prompt_en',
+ render: (text, record, index) => {
+ // 如果text未定义,返回替代文本,例如空字符串''或其他
+ if (!text) {
+ return '无';
+ }
+
+ return (
+ {
+ setModalContent(text);
+ setIsModalOpen(true);
+ }}
+ >
+ {text}
+
+ );
+ }
+ },
+ {
+ title: '失败原因',
+ dataIndex: 'fail_reason',
+ render: (text, record, index) => {
+ // 如果text未定义,返回替代文本,例如空字符串''或其他
+ if (!text) {
+ return '无';
+ }
+
+ return (
+ {
+ setModalContent(text);
+ setIsModalOpen(true);
+ }}
+ >
+ {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);
+ const [showBanner, setShowBanner] = 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);
+
+ 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]);
+
+ useEffect(() => {
+ const mjNotifyEnabled = localStorage.getItem('mj_notify_enabled');
+ if (mjNotifyEnabled !== 'true') {
+ setShowBanner(true);
+ }
+ }, []);
+
+ return (
+ <>
+
+
+ {isAdminUser && showBanner ? : <>>
+ }
+ handleInputChange(value, 'channel_id')} />
+ handleInputChange(value, 'mj_id')} />
+ handleInputChange(value, 'start_timestamp')} />
+ handleInputChange(value, 'end_timestamp')} />
+
+
+
+
+ >
+
+
+ setIsModalOpen(false)}
+ onCancel={() => setIsModalOpen(false)}
+ closable={null}
+ bodyStyle={{ height: '400px', overflow: 'auto' }} // 设置模态框内容区域样式
+ width={800} // 设置模态框宽度
+ >
+ {modalContent}
+
+ setIsModalOpenurl(visible)}
+ />
+
+
+ >
+ );
+};
+
+export default LogsTable;
diff --git a/web/air/src/components/OperationSetting.js b/web/air/src/components/OperationSetting.js
new file mode 100644
index 00000000..b823bb28
--- /dev/null
+++ b/web/air/src/components/OperationSetting.js
@@ -0,0 +1,389 @@
+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: '',
+ CompletionRatio: '',
+ GroupRatio: '',
+ TopUpLink: '',
+ ChatLink: '',
+ QuotaPerUnit: 0,
+ AutomaticDisableChannelEnabled: '',
+ AutomaticEnableChannelEnabled: '',
+ ChannelDisableThreshold: 0,
+ LogConsumeEnabled: '',
+ DisplayInCurrencyEnabled: '',
+ DisplayTokenStatEnabled: '',
+ ApproximateTokenEnabled: '',
+ 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 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 === 'CompletionRatio') {
+ item.value = JSON.stringify(JSON.parse(item.value), null, 2);
+ }
+ if (item.value === '{}') {
+ item.value = '';
+ }
+ newInputs[item.key] = item.value;
+ });
+ setInputs(newInputs);
+ setOriginInputs(newInputs);
+ } else {
+ showError(message);
+ }
+ };
+
+ useEffect(() => {
+ getOptions().then();
+ }, []);
+
+ const updateOption = async (key, value) => {
+ setLoading(true);
+ if (key.endsWith('Enabled')) {
+ value = inputs[key] === 'true' ? 'false' : 'true';
+ }
+ 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')) {
+ 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);
+ }
+ 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['CompletionRatio'] !== inputs.CompletionRatio) {
+ if (!verifyJSON(inputs.CompletionRatio)) {
+ showError('补全倍率不是合法的 JSON 字符串');
+ return;
+ }
+ await updateOption('CompletionRatio', inputs.CompletionRatio);
+ }
+ 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['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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ submitConfig('general').then();
+ }}>保存通用设置
+
+
+
+
+
+
+ {
+ setHistoryTimestamp(value);
+ }} />
+
+ {
+ deleteHistoryLogs().then();
+ }}>清理历史日志
+
+
+
+
+
+
+
+
+
+
+ {
+ submitConfig('monitor').then();
+ }}>保存监控设置
+
+
+
+
+
+
+
+
+ {
+ submitConfig('quota').then();
+ }}>保存额度设置
+
+
+
+
+
+
+
+
+
+
+
+ {
+ submitConfig('ratio').then();
+ }}>保存倍率设置
+
+
+
+ );
+};
+
+export default OperationSetting;
diff --git a/web/air/src/components/OtherSetting.js b/web/air/src/components/OtherSetting.js
new file mode 100644
index 00000000..ae924d9f
--- /dev/null
+++ b/web/air/src/components/OtherSetting.js
@@ -0,0 +1,225 @@
+import React, { useEffect, useState } from 'react';
+import { Button, Divider, Form, Grid, Header, Message, Modal } from 'semantic-ui-react';
+import { API, showError, showSuccess } from '../helpers';
+import { marked } from 'marked';
+import { Link } from 'react-router-dom';
+
+const OtherSetting = () => {
+ let [inputs, setInputs] = useState({
+ Footer: '',
+ Notice: '',
+ About: '',
+ SystemName: '',
+ Logo: '',
+ HomePageContent: '',
+ Theme: ''
+ });
+ let [loading, setLoading] = useState(false);
+ const [showUpdateModal, setShowUpdateModal] = useState(false);
+ const [updateData, setUpdateData] = useState({
+ tag_name: '',
+ content: ''
+ });
+
+ 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 in inputs) {
+ newInputs[item.key] = item.value;
+ }
+ });
+ setInputs(newInputs);
+ } else {
+ showError(message);
+ }
+ };
+
+ useEffect(() => {
+ getOptions().then();
+ }, []);
+
+ const updateOption = async (key, value) => {
+ setLoading(true);
+ 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 }) => {
+ setInputs((inputs) => ({ ...inputs, [name]: value }));
+ };
+
+ const submitNotice = async () => {
+ await updateOption('Notice', inputs.Notice);
+ };
+
+ const submitFooter = async () => {
+ await updateOption('Footer', inputs.Footer);
+ };
+
+ const submitSystemName = async () => {
+ await updateOption('SystemName', inputs.SystemName);
+ };
+
+ const submitTheme = async () => {
+ await updateOption('Theme', inputs.Theme);
+ };
+
+ const submitLogo = async () => {
+ await updateOption('Logo', inputs.Logo);
+ };
+
+ const submitAbout = async () => {
+ await updateOption('About', inputs.About);
+ };
+
+ const submitOption = async (key) => {
+ await updateOption(key, inputs[key]);
+ };
+
+ const openGitHubRelease = () => {
+ window.location =
+ 'https://github.com/songquanpeng/one-api/releases/latest';
+ };
+
+ const checkUpdate = async () => {
+ const res = await API.get(
+ 'https://api.github.com/repos/songquanpeng/one-api/releases/latest'
+ );
+ const { tag_name, body } = res.data;
+ if (tag_name === process.env.REACT_APP_VERSION) {
+ showSuccess(`已是最新版本:${tag_name}`);
+ } else {
+ setUpdateData({
+ tag_name: tag_name,
+ content: marked.parse(body)
+ });
+ setShowUpdateModal(true);
+ }
+ };
+
+ return (
+
+
+ 检查更新
+
+
+
+ 保存公告
+
+
+
+
+
+ 设置系统名称
+
+ 主题名称(当前可用主题)}
+ placeholder='请输入主题名称'
+ value={inputs.Theme}
+ name='Theme'
+ onChange={handleInputChange}
+ />
+
+ 设置主题(重启生效)
+
+
+
+ 设置 Logo
+
+
+
+ submitOption('HomePageContent')}>保存首页内容
+
+
+
+ 保存关于
+ 移除 One API
+ 的版权标识必须首先获得授权,项目维护需要花费大量精力,如果本项目对你有意义,请主动支持本项目。
+
+
+
+ 设置页脚
+
+
+ setShowUpdateModal(false)}
+ onOpen={() => setShowUpdateModal(true)}
+ open={showUpdateModal}
+ >
+ 新版本:{updateData.tag_name}
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default OtherSetting;
diff --git a/web/air/src/components/PasswordResetConfirm.js b/web/air/src/components/PasswordResetConfirm.js
new file mode 100644
index 00000000..071837ae
--- /dev/null
+++ b/web/air/src/components/PasswordResetConfirm.js
@@ -0,0 +1,113 @@
+import React, { useEffect, useState } from 'react';
+import { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react';
+import { API, copy, showError, showNotice } from '../helpers';
+import { useSearchParams } from 'react-router-dom';
+
+const PasswordResetConfirm = () => {
+ const [inputs, setInputs] = useState({
+ email: '',
+ token: ''
+ });
+ const { email, token } = inputs;
+
+ const [loading, setLoading] = useState(false);
+
+ const [disableButton, setDisableButton] = useState(false);
+ const [countdown, setCountdown] = useState(30);
+
+ const [newPassword, setNewPassword] = useState('');
+
+ const [searchParams, setSearchParams] = useSearchParams();
+ useEffect(() => {
+ let token = searchParams.get('token');
+ let email = searchParams.get('email');
+ setInputs({
+ token,
+ email
+ });
+ }, []);
+
+ 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);
+ }, [disableButton, countdown]);
+
+ async function handleSubmit(e) {
+ setDisableButton(true);
+ if (!email) return;
+ setLoading(true);
+ const res = await API.post(`/api/user/reset`, {
+ email,
+ token
+ });
+ const { success, message } = res.data;
+ if (success) {
+ let password = res.data.data;
+ setNewPassword(password);
+ await copy(password);
+ showNotice(`新密码已复制到剪贴板:${password}`);
+ } else {
+ showError(message);
+ }
+ setLoading(false);
+ }
+
+ return (
+
+
+
+
+
+
+ );
+};
+
+export default PasswordResetConfirm;
diff --git a/web/air/src/components/PasswordResetForm.js b/web/air/src/components/PasswordResetForm.js
new file mode 100644
index 00000000..ff3eaadb
--- /dev/null
+++ b/web/air/src/components/PasswordResetForm.js
@@ -0,0 +1,102 @@
+import React, { useEffect, useState } from 'react';
+import { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react';
+import { API, showError, showInfo, showSuccess } from '../helpers';
+import Turnstile from 'react-turnstile';
+
+const PasswordResetForm = () => {
+ const [inputs, setInputs] = useState({
+ email: ''
+ });
+ const { email } = inputs;
+
+ const [loading, setLoading] = useState(false);
+ const [turnstileEnabled, setTurnstileEnabled] = useState(false);
+ const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
+ const [turnstileToken, setTurnstileToken] = useState('');
+ const [disableButton, setDisableButton] = useState(false);
+ const [countdown, setCountdown] = useState(30);
+
+ 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);
+ }, [disableButton, countdown]);
+
+ function handleChange(e) {
+ const { name, value } = e.target;
+ setInputs(inputs => ({ ...inputs, [name]: value }));
+ }
+
+ async function handleSubmit(e) {
+ setDisableButton(true);
+ if (!email) return;
+ if (turnstileEnabled && turnstileToken === '') {
+ showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
+ return;
+ }
+ setLoading(true);
+ const res = await API.get(
+ `/api/reset_password?email=${email}&turnstile=${turnstileToken}`
+ );
+ const { success, message } = res.data;
+ if (success) {
+ showSuccess('重置邮件发送成功,请检查邮箱!');
+ setInputs({ ...inputs, email: '' });
+ } else {
+ showError(message);
+ }
+ setLoading(false);
+ }
+
+ return (
+
+
+
+
+
+
+ );
+};
+
+export default PasswordResetForm;
diff --git a/web/air/src/components/PersonalSetting.js b/web/air/src/components/PersonalSetting.js
new file mode 100644
index 00000000..45a5b776
--- /dev/null
+++ b/web/air/src/components/PersonalSetting.js
@@ -0,0 +1,653 @@
+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 {
+ 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 [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'))
+
+ 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]);
+
+ const handleInputChange = (name, value) => {
+ setInputs((inputs) => ({ ...inputs, [name]: value }));
+ };
+
+ 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 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 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 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 handleAffLinkClick = async (e) => {
+ e.target.select();
+ await copy(e.target.value);
+ showSuccess(`邀请链接已复制到剪切板`);
+ };
+
+ const handleSystemTokenClick = 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 res = await API.delete('/api/user/self');
+ const { success, message } = res.data;
+
+ if (success) {
+ showSuccess('账户已删除!');
+ await API.get('/api/user/logout');
+ userDispatch({ type: 'logout' });
+ localStorage.removeItem('user');
+ navigate('/login');
+ } else {
+ showError(message);
+ }
+ };
+
+ 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 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 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 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 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 getUsername = () => {
+ if (userState.user) {
+ return userState.user.username;
+ } else {
+ return 'null';
+ }
+ };
+
+ const handleCancel = () => {
+ setOpenTransfer(false);
+ };
+
+ const copyText = async (text) => {
+ if (await copy(text)) {
+ showSuccess('已复制:' + text);
+ } else {
+ // setSearchKeyword(text);
+ Modal.error({ title: '无法复制到剪贴板,请手动复制', content: text });
+ }
+ };
+
+ return (
+
+
+
+
+
+ {`可用额度${renderQuotaWithPrompt(userState?.user?.aff_quota)}`}
+
+
+
+
{`划转额度${renderQuotaWithPrompt(transferAmount)} 最低` + renderQuota(getQuotaPerUnit())}
+
+ setTransferAmount(value)} disabled={false}>
+
+
+
+
+
+ {typeof getUsername() === 'string' && getUsername().slice(0, 1)}
+ }
+ title={{getUsername()}}
+ description={isRoot() ? 管理员 : 普通用户}
+ >
+ }
+ headerExtraContent={
+ <>
+
+ {'ID: ' + userState?.user?.id}
+ {userState?.user?.group}
+
+ >
+ }
+ footer={
+
+ {renderQuota(userState?.user?.quota)}
+ {renderQuota(userState?.user?.used_quota)}
+ {userState.user?.request_count}
+
+ }
+ >
+ 调用信息
+ {/* 可用模型
+
+
+ {models.map((model) => (
+ {
+ copyText(model);
+ }}>
+ {model}
+
+ ))}
+
+
*/}
+
+ {/*
+ 邀请链接
+
+
+ }
+ >
+ 邀请信息
+
+
+
+
+ {
+ renderQuota(userState?.user?.aff_quota)
+ }
+
+
+
+ {renderQuota(userState?.user?.aff_history_quota)}
+ {userState?.user?.aff_count}
+
+
+ */}
+
+ 邀请链接
+
+
+
+ 个人信息
+
+
邮箱
+
+
+
+
+
+
+
+
+
+
+
微信
+
+
+
+
+
+
+
+
+
+
+
GitHub
+
+
+
+
+
+
+
+
+
+
+ {/*
+
Telegram
+
+
+
+
+
+ {status.telegram_oauth ?
+ userState.user.telegram_id !== '' ?
+ :
+ :
+ }
+
+
+
*/}
+
+
+
+
+
+
+
+
+ {systemToken && (
+
+ )}
+ {
+ status.wechat_login && (
+
+ )
+ }
+
setShowWeChatBindModal(false)}
+ // onOpen={() => setShowWeChatBindModal(true)}
+ visible={showWeChatBindModal}
+ size={'mini'}
+ >
+
+
+
+ 微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)
+
+
+ handleInputChange('wechat_verification_code', v)}
+ />
+
+
+
+
+ setShowEmailBindModal(false)}
+ // onOpen={() => setShowEmailBindModal(true)}
+ onOk={bindEmail}
+ visible={showEmailBindModal}
+ size={'small'}
+ centered={true}
+ maskClosable={false}
+ >
+ 绑定邮箱地址
+
+ handleInputChange('email', value)}
+ name="email"
+ type="email"
+ />
+
+
+
+ handleInputChange('email_verification_code', value)}
+ />
+
+ {turnstileEnabled ? (
+ {
+ setTurnstileToken(token);
+ }}
+ />
+ ) : (
+ <>>
+ )}
+
+ setShowAccountDeleteModal(false)}
+ visible={showAccountDeleteModal}
+ size={'small'}
+ centered={true}
+ onOk={deleteAccount}
+ >
+
+
+
+
+ handleInputChange('self_account_deletion_confirmation', value)}
+ />
+ {turnstileEnabled ? (
+ {
+ setTurnstileToken(token);
+ }}
+ />
+ ) : (
+ <>>
+ )}
+
+
+ setShowChangePasswordModal(false)}
+ visible={showChangePasswordModal}
+ size={'small'}
+ centered={true}
+ onOk={changePassword}
+ >
+
+ handleInputChange('set_new_password', value)}
+ />
+ handleInputChange('set_new_password_confirmation', value)}
+ />
+ {turnstileEnabled ? (
+ {
+ setTurnstileToken(token);
+ }}
+ />
+ ) : (
+ <>>
+ )}
+
+
+
+
+
+
+
+ );
+};
+
+export default PersonalSetting;
diff --git a/web/air/src/components/PrivateRoute.js b/web/air/src/components/PrivateRoute.js
new file mode 100644
index 00000000..9ef826c1
--- /dev/null
+++ b/web/air/src/components/PrivateRoute.js
@@ -0,0 +1,13 @@
+import { Navigate } from 'react-router-dom';
+
+import { history } from '../helpers';
+
+
+function PrivateRoute({ children }) {
+ if (!localStorage.getItem('user')) {
+ return ;
+ }
+ return children;
+}
+
+export { PrivateRoute };
\ No newline at end of file
diff --git a/web/air/src/components/RedemptionsTable.js b/web/air/src/components/RedemptionsTable.js
new file mode 100644
index 00000000..89e4ce20
--- /dev/null
+++ b/web/air/src/components/RedemptionsTable.js
@@ -0,0 +1,406 @@
+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, Form, Modal, Popconfirm, Popover, Table, Tag } from '@douyinfe/semi-ui';
+import EditRedemption from '../pages/Redemption/EditRedemption';
+
+function renderTimestamp(timestamp) {
+ return (
+ <>
+ {timestamp2string(timestamp)}
+ >
+ );
+}
+
+function renderStatus(status) {
+ switch (status) {
+ case 1:
+ return 未使用;
+ case 2:
+ return 已禁用 ;
+ case 3:
+ return 已使用 ;
+ default:
+ return 未知状态 ;
+ }
+}
+
+const RedemptionsTable = () => {
+ const columns = [
+ {
+ title: 'ID',
+ dataIndex: 'id'
+ },
+ {
+ title: '名称',
+ dataIndex: 'name'
+ },
+ {
+ title: '状态',
+ dataIndex: 'status',
+ key: 'status',
+ render: (text, record, index) => {
+ return (
+
+ {renderStatus(text)}
+
+ );
+ }
+ },
+ {
+ title: '额度',
+ dataIndex: 'quota',
+ render: (text, record, index) => {
+ return (
+
+ {renderQuota(parseInt(text))}
+
+ );
+ }
+ },
+ {
+ title: '创建时间',
+ dataIndex: 'created_time',
+ render: (text, record, index) => {
+ return (
+
+ {renderTimestamp(text)}
+
+ );
+ }
+ },
+ // {
+ // title: '兑换人ID',
+ // dataIndex: 'used_user_id',
+ // render: (text, record, index) => {
+ // return (
+ //
+ // {text === 0 ? '无' : text}
+ //
+ // );
+ // }
+ // },
+ {
+ title: '',
+ dataIndex: 'operate',
+ render: (text, record, index) => (
+
+
+
+
+
+
{
+ manageRedemption(record.id, 'delete', record).then(
+ () => {
+ removeRecord(record.key);
+ }
+ );
+ }}
+ >
+
+
+ {
+ record.status === 1 ?
+
:
+
+ }
+
+
+ )
+ }
+ ];
+
+ 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 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 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);
+
+ 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 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);
+ });
+ }, []);
+
+ 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') {
+
+ } 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 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 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);
+ }
+ };
+
+ const handleRow = (record, index) => {
+ if (record.status !== 1) {
+ return {
+ style: {
+ background: 'var(--semi-color-disabled-border)'
+ }
+ };
+ } else {
+ return {};
+ }
+ };
+
+ return (
+ <>
+
+
+
+
+ `第 ${page.currentStart} - ${page.currentEnd} 条,共 ${redemptions.length} 条`,
+ // onPageSizeChange: (size) => {
+ // setPageSize(size);
+ // setActivePage(1);
+ // },
+ onPageChange: handlePageChange
+ }} loading={loading} rowSelection={rowSelection} onRow={handleRow}>
+
+
+
+ >
+ );
+};
+
+export default RedemptionsTable;
diff --git a/web/air/src/components/RegisterForm.js b/web/air/src/components/RegisterForm.js
new file mode 100644
index 00000000..1f26b63f
--- /dev/null
+++ b/web/air/src/components/RegisterForm.js
@@ -0,0 +1,194 @@
+import React, { useEffect, useState } from 'react';
+import { Button, Form, Grid, Header, Image, Message, Segment } from 'semantic-ui-react';
+import { Link, useNavigate } from 'react-router-dom';
+import { API, getLogo, showError, showInfo, showSuccess } from '../helpers';
+import Turnstile from 'react-turnstile';
+
+const RegisterForm = () => {
+ const [inputs, setInputs] = useState({
+ username: '',
+ password: '',
+ password2: '',
+ email: '',
+ verification_code: ''
+ });
+ const { username, password, password2 } = inputs;
+ const [showEmailVerification, setShowEmailVerification] = useState(false);
+ const [turnstileEnabled, setTurnstileEnabled] = useState(false);
+ const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
+ const [turnstileToken, setTurnstileToken] = useState('');
+ const [loading, setLoading] = useState(false);
+ const logo = getLogo();
+ let affCode = new URLSearchParams(window.location.search).get('aff');
+ if (affCode) {
+ localStorage.setItem('aff', affCode);
+ }
+
+ useEffect(() => {
+ let status = localStorage.getItem('status');
+ if (status) {
+ status = JSON.parse(status);
+ setShowEmailVerification(status.email_verification);
+ if (status.turnstile_check) {
+ setTurnstileEnabled(true);
+ setTurnstileSiteKey(status.turnstile_site_key);
+ }
+ }
+ });
+
+ let navigate = useNavigate();
+
+ function handleChange(e) {
+ const { name, value } = e.target;
+ console.log(name, value);
+ setInputs((inputs) => ({ ...inputs, [name]: value }));
+ }
+
+ async function handleSubmit(e) {
+ if (password.length < 8) {
+ showInfo('密码长度不得小于 8 位!');
+ return;
+ }
+ if (password !== password2) {
+ showInfo('两次输入的密码不一致');
+ return;
+ }
+ if (username && password) {
+ if (turnstileEnabled && turnstileToken === '') {
+ showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
+ return;
+ }
+ setLoading(true);
+ if (!affCode) {
+ affCode = localStorage.getItem('aff');
+ }
+ inputs.aff_code = affCode;
+ const res = await API.post(
+ `/api/user/register?turnstile=${turnstileToken}`,
+ inputs
+ );
+ const { success, message } = res.data;
+ if (success) {
+ navigate('/login');
+ showSuccess('注册成功!');
+ } else {
+ showError(message);
+ }
+ setLoading(false);
+ }
+ }
+
+ const sendVerificationCode = async () => {
+ if (inputs.email === '') return;
+ 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);
+ };
+
+ return (
+
+
+
+
+
+ 已有账户?
+
+ 点击登录
+
+
+
+
+ );
+};
+
+export default RegisterForm;
diff --git a/web/air/src/components/SiderBar.js b/web/air/src/components/SiderBar.js
new file mode 100644
index 00000000..b3da272f
--- /dev/null
+++ b/web/air/src/components/SiderBar.js
@@ -0,0 +1,214 @@
+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 } from '../helpers';
+import '../index.css';
+
+import {
+ IconCalendarClock,
+ IconComment,
+ IconCreditCard,
+ IconGift,
+ IconHistogram,
+ IconHome,
+ IconImage,
+ IconKey,
+ IconLayers,
+ IconSetting,
+ IconUser
+} from '@douyinfe/semi-icons';
+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';
+
+ 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:
+ },
+ {
+ text: '渠道',
+ itemKey: 'channel',
+ to: '/channel',
+ icon: ,
+ className: isAdmin() ? 'semi-navigation-item-normal' : 'tableHiddle'
+ },
+ {
+ text: '聊天',
+ itemKey: 'chat',
+ to: '/chat',
+ icon: ,
+ className: localStorage.getItem('chat_link') ? 'semi-navigation-item-normal' : 'tableHiddle'
+ },
+ {
+ text: '令牌',
+ itemKey: 'token',
+ to: '/token',
+ icon:
+ },
+ {
+ text: '兑换',
+ itemKey: 'redemption',
+ to: '/redemption',
+ icon: ,
+ className: isAdmin() ? 'semi-navigation-item-normal' : 'tableHiddle'
+ },
+ {
+ text: '充值',
+ itemKey: 'topup',
+ to: '/topup',
+ icon:
+ },
+ {
+ text: '用户',
+ itemKey: 'user',
+ to: '/user',
+ icon: ,
+ className: isAdmin() ? 'semi-navigation-item-normal' : 'tableHiddle'
+ },
+ {
+ text: '日志',
+ itemKey: 'log',
+ to: '/log',
+ icon:
+ },
+ {
+ text: '数据看板',
+ itemKey: 'detail',
+ to: '/detail',
+ icon: ,
+ className: localStorage.getItem('enable_data_export') === 'true' ? 'semi-navigation-item-normal' : 'tableHiddle'
+ },
+ {
+ text: '绘图',
+ itemKey: 'midjourney',
+ to: '/midjourney',
+ icon: ,
+ className: localStorage.getItem('enable_drawing') === 'true' ? 'semi-navigation-item-normal' : 'tableHiddle'
+ },
+ {
+ text: '设置',
+ itemKey: 'setting',
+ to: '/setting',
+ icon:
+ }
+ // {
+ // text: '关于',
+ // itemKey: 'about',
+ // to: '/about',
+ // icon:
+ // }
+ ], [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);
+ localStorage.setItem('mj_notify_enabled', data.mj_notify_enabled);
+ 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');
+ });
+ }, []);
+
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+};
+
+export default SiderBar;
diff --git a/web/air/src/components/SystemSetting.js b/web/air/src/components/SystemSetting.js
new file mode 100644
index 00000000..09b98665
--- /dev/null
+++ b/web/air/src/components/SystemSetting.js
@@ -0,0 +1,590 @@
+import React, { useEffect, useState } from 'react';
+import { Button, Divider, Form, Grid, Header, Modal, Message } from 'semantic-ui-react';
+import { API, removeTrailingSlash, showError } from '../helpers';
+
+const SystemSetting = () => {
+ let [inputs, setInputs] = useState({
+ PasswordLoginEnabled: '',
+ PasswordRegisterEnabled: '',
+ EmailVerificationEnabled: '',
+ GitHubOAuthEnabled: '',
+ GitHubClientId: '',
+ GitHubClientSecret: '',
+ Notice: '',
+ SMTPServer: '',
+ SMTPPort: '',
+ SMTPAccount: '',
+ SMTPFrom: '',
+ SMTPToken: '',
+ ServerAddress: '',
+ Footer: '',
+ WeChatAuthEnabled: '',
+ WeChatServerAddress: '',
+ WeChatServerToken: '',
+ WeChatAccountQRCodeImageURL: '',
+ MessagePusherAddress: '',
+ MessagePusherToken: '',
+ TurnstileCheckEnabled: '',
+ TurnstileSiteKey: '',
+ TurnstileSecretKey: '',
+ RegisterEnabled: '',
+ EmailDomainRestrictionEnabled: '',
+ EmailDomainWhitelist: ''
+ });
+ 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) => {
+ newInputs[item.key] = item.value;
+ });
+ setInputs({
+ ...newInputs,
+ EmailDomainWhitelist: newInputs.EmailDomainWhitelist.split(',')
+ });
+ setOriginInputs(newInputs);
+
+ setEmailDomainWhitelist(newInputs.EmailDomainWhitelist.split(',').map((item) => {
+ return { key: item, text: item, value: item };
+ }));
+ } else {
+ showError(message);
+ }
+ };
+
+ useEffect(() => {
+ getOptions().then();
+ }, []);
+
+ const updateOption = async (key, value) => {
+ setLoading(true);
+ switch (key) {
+ case 'PasswordLoginEnabled':
+ case 'PasswordRegisterEnabled':
+ case 'EmailVerificationEnabled':
+ case 'GitHubOAuthEnabled':
+ case 'WeChatAuthEnabled':
+ 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(',');
+ }
+ setInputs((inputs) => ({
+ ...inputs, [key]: value
+ }));
+ } else {
+ showError(message);
+ }
+ setLoading(false);
+ };
+
+ 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 === 'GitHubClientId' ||
+ name === 'GitHubClientSecret' ||
+ name === 'WeChatServerAddress' ||
+ name === 'WeChatServerToken' ||
+ name === 'WeChatAccountQRCodeImageURL' ||
+ name === 'TurnstileSiteKey' ||
+ name === 'TurnstileSecretKey' ||
+ name === 'EmailDomainWhitelist'
+ ) {
+ setInputs((inputs) => ({ ...inputs, [name]: value }));
+ } else {
+ await updateOption(name, value);
+ }
+ };
+
+ 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 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 submitMessagePusher = async () => {
+ if (originInputs['MessagePusherAddress'] !== inputs.MessagePusherAddress) {
+ await updateOption(
+ 'MessagePusherAddress',
+ removeTrailingSlash(inputs.MessagePusherAddress)
+ );
+ }
+ if (
+ originInputs['MessagePusherToken'] !== inputs.MessagePusherToken &&
+ inputs.MessagePusherToken !== ''
+ ) {
+ await updateOption('MessagePusherToken', inputs.MessagePusherToken);
+ }
+ };
+
+ 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 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 (
+
+
+
+
+
+
+ 更新服务器地址
+
+
+
+
+
+ {
+ showPasswordWarningModal &&
+ setShowPasswordWarningModal(false)}
+ size={'tiny'}
+ style={{ maxWidth: '450px' }}
+ >
+ 警告
+
+ 取消密码登录将导致所有未绑定其他登录方式的用户(包括管理员)无法通过密码登录,确认取消?
+
+
+
+
+
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+ 配置邮箱域名白名单
+ 用以防止恶意用户利用临时邮箱批量注册
+
+
+
+
+
+
+ {
+ submitNewRestrictedDomain();
+ }}>填入
+ }
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ submitNewRestrictedDomain();
+ }
+ }}
+ autoComplete='new-password'
+ placeholder='输入新的允许的邮箱域名'
+ value={restrictedDomainInput}
+ onChange={(e, { value }) => {
+ setRestrictedDomainInput(value);
+ }}
+ />
+
+ 保存邮箱域名白名单设置
+
+
+ 配置 SMTP
+ 用以支持系统的邮件发送
+
+
+
+
+
+
+
+
+
+
+ 保存 SMTP 设置
+
+
+ 配置 GitHub OAuth App
+
+ 用以支持通过 GitHub 进行登录注册,
+
+ 点击此处
+
+ 管理你的 GitHub OAuth App
+
+
+
+ Homepage URL 填 {inputs.ServerAddress}
+ ,Authorization callback URL 填{' '}
+ {`${inputs.ServerAddress}/oauth/github`}
+
+
+
+
+
+
+ 保存 GitHub OAuth 设置
+
+
+
+ 配置 WeChat Server
+
+ 用以支持通过微信进行登录注册,
+
+ 点击此处
+
+ 了解 WeChat Server
+
+
+
+
+
+
+
+
+ 保存 WeChat Server 设置
+
+
+
+ 配置 Message Pusher
+
+ 用以推送报警信息,
+
+ 点击此处
+
+ 了解 Message Pusher
+
+
+
+
+
+
+
+ 保存 Message Pusher 设置
+
+
+
+ 配置 Turnstile
+
+ 用以支持用户校验,
+
+ 点击此处
+
+ 管理你的 Turnstile Sites,推荐选择 Invisible Widget Type
+
+
+
+
+
+
+
+ 保存 Turnstile 设置
+
+
+
+
+ );
+};
+
+export default SystemSetting;
diff --git a/web/air/src/components/TokensTable.js b/web/air/src/components/TokensTable.js
new file mode 100644
index 00000000..9c4deb6e
--- /dev/null
+++ b/web/air/src/components/TokensTable.js
@@ -0,0 +1,586 @@
+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' }
+];
+
+const OPEN_LINK_OPTIONS = [
+ { key: 'ama', text: 'ChatGPT Web & Midjourney', value: 'ama' },
+ { key: 'opencat', text: 'OpenCat', value: 'opencat' }
+];
+
+function renderTimestamp(timestamp) {
+ return (
+ <>
+ {timestamp2string(timestamp)}
+ >
+ );
+}
+
+function renderStatus(status, model_limits_enabled = false) {
+ switch (status) {
+ case 1:
+ if (model_limits_enabled) {
+ return 已启用:限制模型;
+ } else {
+ return 已启用;
+ }
+ case 2:
+ return 已禁用 ;
+ case 3:
+ return 已过期 ;
+ case 4:
+ return 已耗尽 ;
+ default:
+ return 未知状态 ;
+ }
+}
+
+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 (
+
+ {renderStatus(text, record.model_limits_enabled)}
+
+ );
+ }
+ },
+ {
+ title: '已用额度',
+ dataIndex: 'used_quota',
+ render: (text, record, index) => {
+ return (
+
+ {renderQuota(parseInt(text))}
+
+ );
+ }
+ },
+ {
+ title: '剩余额度',
+ dataIndex: 'remain_quota',
+ render: (text, record, index) => {
+ return (
+
+ {record.unlimited_quota ? 无限制 :
+ {renderQuota(parseInt(text))}}
+
+ );
+ }
+ },
+ {
+ title: '创建时间',
+ dataIndex: 'created_time',
+ render: (text, record, index) => {
+ return (
+
+ {renderTimestamp(text)}
+
+ );
+ }
+ },
+ {
+ title: '过期时间',
+ dataIndex: 'expired_time',
+ render: (text, record, index) => {
+ return (
+
+ {record.expired_time === -1 ? '永不过期' : renderTimestamp(text)}
+
+ );
+ }
+ },
+ {
+ title: '',
+ dataIndex: 'operate',
+ render: (text, record, index) => (
+
+
+
+
+
+
+
+ {
+ 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);
+ }
+ }
+ ]
+ }
+ >
+ }>
+
+
+
{
+ manageToken(record.id, 'delete', record).then(
+ () => {
+ removeRecord(record.key);
+ }
+ );
+ }}
+ >
+
+
+ {
+ record.status === 1 ?
+
:
+
+ }
+
+
+ )
+ }
+ ];
+
+ 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 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);
+ }
+ 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}"}`;
+ }
+
+ 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;
+ }
+
+ 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);
+ };
+
+
+ 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 handleRow = (record, index) => {
+ if (record.status !== 1) {
+ return {
+ style: {
+ background: 'var(--semi-color-disabled-border)'
+ }
+ };
+ } else {
+ return {};
+ }
+ };
+
+ return (
+ <>
+
+
+ {/* */}
+
+
+
+ `第 ${page.currentStart} - ${page.currentEnd} 条,共 ${tokens.length} 条`,
+ onPageSizeChange: (size) => {
+ setPageSize(size);
+ setActivePage(1);
+ },
+ onPageChange: handlePageChange
+ }} loading={loading} rowSelection={rowSelection} onRow={handleRow}>
+
+
+
+ >
+ );
+};
+
+export default TokensTable;
diff --git a/web/air/src/components/UsersTable.js b/web/air/src/components/UsersTable.js
new file mode 100644
index 00000000..f3de46d6
--- /dev/null
+++ b/web/air/src/components/UsersTable.js
@@ -0,0 +1,338 @@
+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 普通用户;
+ case 10:
+ return 管理员;
+ case 100:
+ return 超级管理员;
+ default:
+ return 未知身份;
+ }
+}
+
+const UsersTable = () => {
+ const columns = [{
+ title: 'ID', dataIndex: 'id'
+ }, {
+ title: '用户名', dataIndex: 'username'
+ }, {
+ title: '分组', dataIndex: 'group', render: (text, record, index) => {
+ return (
+ {renderGroup(text)}
+
);
+ }
+ }, {
+ title: '统计信息', dataIndex: 'info', render: (text, record, index) => {
+ return (
+
+
+ {renderQuota(record.quota)}
+
+
+ {renderQuota(record.used_quota)}
+
+
+ {renderNumber(record.request_count)}
+
+
+
);
+ }
+ },
+ // {
+ // title: '邀请信息', dataIndex: 'invite', render: (text, record, index) => {
+ // return (
+ //
+ //
+ // {renderNumber(record.aff_count)}
+ //
+ //
+ // {renderQuota(record.aff_history_quota)}
+ //
+ //
+ // {record.inviter_id === 0 ? 无 :
+ // {record.inviter_id}}
+ //
+ //
+ //
);
+ // }
+ // },
+ {
+ title: '角色', dataIndex: 'role', render: (text, record, index) => {
+ return (
+ {renderRole(text)}
+
);
+ }
+ },
+ {
+ title: '状态', dataIndex: 'status', render: (text, record, index) => {
+ return (
+ {renderStatus(text)}
+
);
+ }
+ },
+ {
+ title: '', dataIndex: 'operate', render: (text, record, index) => (
+ <>
+
{
+ manageUser(record.username, 'promote', record);
+ }}
+ >
+
+
+
{
+ manageUser(record.username, 'demote', record);
+ }}
+ >
+
+
+ {record.status === 1 ?
+
:
+
}
+
+ >
+
{
+ manageUser(record.username, 'delete', record).then(() => {
+ removeRecord(record.id);
+ });
+ }}
+ >
+
+
+
)
+ }];
+
+ 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);
+
+ 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 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);
+ });
+ }, []);
+
+ 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);
+ }
+ };
+
+ const renderStatus = (status) => {
+ switch (status) {
+ case 1:
+ return 已激活;
+ case 2:
+ return (
+ 已封禁
+ );
+ default:
+ return (
+ 未知状态
+ );
+ }
+ };
+
+ 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 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 pageData = users.slice((activePage - 1) * ITEMS_PER_PAGE, activePage * ITEMS_PER_PAGE);
+
+ const closeAddUser = () => {
+ setShowAddUser(false);
+ };
+
+ const closeEditUser = () => {
+ setShowEditUser(false);
+ setEditingUser({
+ id: undefined
+ });
+ };
+
+ const refresh = async () => {
+ if (searchKeyword === '') {
+ await loadUsers(activePage - 1);
+ } else {
+ await searchUsers();
+ }
+ };
+
+ return (
+ <>
+
+
+ handleKeywordChange(value)}
+ />
+
+
+
+
+ >
+ );
+};
+
+export default UsersTable;
diff --git a/web/air/src/components/WeChatIcon.js b/web/air/src/components/WeChatIcon.js
new file mode 100644
index 00000000..22210d95
--- /dev/null
+++ b/web/air/src/components/WeChatIcon.js
@@ -0,0 +1,24 @@
+import React from 'react';
+import { Icon } from '@douyinfe/semi-ui';
+
+const WeChatIcon = () => {
+ function CustomIcon() {
+ return ;
+ }
+
+ return (
+
+ } />
+
+ );
+};
+
+export default WeChatIcon;
diff --git a/web/air/src/components/utils.js b/web/air/src/components/utils.js
new file mode 100644
index 00000000..5363ba5e
--- /dev/null
+++ b/web/air/src/components/utils.js
@@ -0,0 +1,20 @@
+import { API, showError } from '../helpers';
+
+export async function getOAuthState() {
+ const res = await API.get('/api/oauth/state');
+ const { success, message, data } = res.data;
+ if (success) {
+ return data;
+ } else {
+ showError(message);
+ return '';
+ }
+}
+
+export async function onGitHubOAuthClicked(github_client_id) {
+ const state = await getOAuthState();
+ if (!state) return;
+ window.open(
+ `https://github.com/login/oauth/authorize?client_id=${github_client_id}&state=${state}&scope=user:email`
+ );
+}
\ No newline at end of file
diff --git a/web/air/src/constants/channel.constants.js b/web/air/src/constants/channel.constants.js
new file mode 100644
index 00000000..4bf035f9
--- /dev/null
+++ b/web/air/src/constants/channel.constants.js
@@ -0,0 +1,37 @@
+export const CHANNEL_OPTIONS = [
+ { key: 1, text: 'OpenAI', value: 1, color: 'green' },
+ { key: 14, text: 'Anthropic Claude', value: 14, color: 'black' },
+ { key: 3, text: 'Azure OpenAI', value: 3, color: 'olive' },
+ { key: 11, text: 'Google PaLM2', value: 11, color: 'orange' },
+ { key: 24, text: 'Google Gemini', value: 24, color: 'orange' },
+ { key: 28, text: 'Mistral AI', value: 28, color: 'orange' },
+ { key: 15, text: '百度文心千帆', value: 15, color: 'blue' },
+ { key: 17, text: '阿里通义千问', value: 17, color: 'orange' },
+ { key: 18, text: '讯飞星火认知', value: 18, color: 'blue' },
+ { key: 16, text: '智谱 ChatGLM', value: 16, color: 'violet' },
+ { key: 19, text: '360 智脑', value: 19, color: 'blue' },
+ { key: 25, text: 'Moonshot AI', value: 25, color: 'black' },
+ { key: 23, text: '腾讯混元', value: 23, color: 'teal' },
+ { key: 26, text: '百川大模型', value: 26, color: 'orange' },
+ { key: 27, text: 'MiniMax', value: 27, color: 'red' },
+ { key: 29, text: 'Groq', value: 29, color: 'orange' },
+ { key: 30, text: 'Ollama', value: 30, color: 'black' },
+ { key: 31, text: '零一万物', value: 31, color: 'green' },
+ { key: 8, text: '自定义渠道', value: 8, color: 'pink' },
+ { key: 22, text: '知识库:FastGPT', value: 22, color: 'blue' },
+ { key: 21, text: '知识库:AI Proxy', value: 21, color: 'purple' },
+ { key: 20, text: '代理:OpenRouter', value: 20, color: 'black' },
+ { key: 2, text: '代理:API2D', value: 2, color: 'blue' },
+ { key: 5, text: '代理:OpenAI-SB', value: 5, color: 'brown' },
+ { key: 7, text: '代理:OhMyGPT', value: 7, color: 'purple' },
+ { key: 10, text: '代理:AI Proxy', value: 10, color: 'purple' },
+ { key: 4, text: '代理:CloseAI', value: 4, color: 'teal' },
+ { key: 6, text: '代理:OpenAI Max', value: 6, color: 'violet' },
+ { key: 9, text: '代理:AI.LS', value: 9, color: 'yellow' },
+ { key: 12, text: '代理:API2GPT', value: 12, color: 'blue' },
+ { key: 13, text: '代理:AIGC2D', value: 13, color: 'purple' }
+];
+
+for (let i = 0; i < CHANNEL_OPTIONS.length; i++) {
+ CHANNEL_OPTIONS[i].label = CHANNEL_OPTIONS[i].text;
+}
\ No newline at end of file
diff --git a/web/air/src/constants/common.constant.js b/web/air/src/constants/common.constant.js
new file mode 100644
index 00000000..1a37d5f6
--- /dev/null
+++ b/web/air/src/constants/common.constant.js
@@ -0,0 +1 @@
+export const ITEMS_PER_PAGE = 10; // this value must keep same as the one defined in backend!
diff --git a/web/air/src/constants/index.js b/web/air/src/constants/index.js
new file mode 100644
index 00000000..e83152bc
--- /dev/null
+++ b/web/air/src/constants/index.js
@@ -0,0 +1,4 @@
+export * from './toast.constants';
+export * from './user.constants';
+export * from './common.constant';
+export * from './channel.constants';
\ No newline at end of file
diff --git a/web/air/src/constants/toast.constants.js b/web/air/src/constants/toast.constants.js
new file mode 100644
index 00000000..50684722
--- /dev/null
+++ b/web/air/src/constants/toast.constants.js
@@ -0,0 +1,7 @@
+export const toastConstants = {
+ SUCCESS_TIMEOUT: 1500,
+ INFO_TIMEOUT: 3000,
+ ERROR_TIMEOUT: 5000,
+ WARNING_TIMEOUT: 10000,
+ NOTICE_TIMEOUT: 20000
+};
diff --git a/web/air/src/constants/user.constants.js b/web/air/src/constants/user.constants.js
new file mode 100644
index 00000000..2680d8ef
--- /dev/null
+++ b/web/air/src/constants/user.constants.js
@@ -0,0 +1,19 @@
+export const userConstants = {
+ REGISTER_REQUEST: 'USERS_REGISTER_REQUEST',
+ REGISTER_SUCCESS: 'USERS_REGISTER_SUCCESS',
+ REGISTER_FAILURE: 'USERS_REGISTER_FAILURE',
+
+ LOGIN_REQUEST: 'USERS_LOGIN_REQUEST',
+ LOGIN_SUCCESS: 'USERS_LOGIN_SUCCESS',
+ LOGIN_FAILURE: 'USERS_LOGIN_FAILURE',
+
+ LOGOUT: 'USERS_LOGOUT',
+
+ GETALL_REQUEST: 'USERS_GETALL_REQUEST',
+ GETALL_SUCCESS: 'USERS_GETALL_SUCCESS',
+ GETALL_FAILURE: 'USERS_GETALL_FAILURE',
+
+ DELETE_REQUEST: 'USERS_DELETE_REQUEST',
+ DELETE_SUCCESS: 'USERS_DELETE_SUCCESS',
+ DELETE_FAILURE: 'USERS_DELETE_FAILURE'
+};
diff --git a/web/air/src/context/Status/index.js b/web/air/src/context/Status/index.js
new file mode 100644
index 00000000..71f0682b
--- /dev/null
+++ b/web/air/src/context/Status/index.js
@@ -0,0 +1,19 @@
+// contexts/User/index.jsx
+
+import React from 'react';
+import { initialState, reducer } from './reducer';
+
+export const StatusContext = React.createContext({
+ state: initialState,
+ dispatch: () => null,
+});
+
+export const StatusProvider = ({ children }) => {
+ const [state, dispatch] = React.useReducer(reducer, initialState);
+
+ return (
+
+ {children}
+
+ );
+};
\ No newline at end of file
diff --git a/web/air/src/context/Status/reducer.js b/web/air/src/context/Status/reducer.js
new file mode 100644
index 00000000..ec9ac6ae
--- /dev/null
+++ b/web/air/src/context/Status/reducer.js
@@ -0,0 +1,20 @@
+export const reducer = (state, action) => {
+ switch (action.type) {
+ case 'set':
+ return {
+ ...state,
+ status: action.payload,
+ };
+ case 'unset':
+ return {
+ ...state,
+ status: undefined,
+ };
+ default:
+ return state;
+ }
+};
+
+export const initialState = {
+ status: undefined,
+};
diff --git a/web/air/src/context/User/index.js b/web/air/src/context/User/index.js
new file mode 100644
index 00000000..c6671591
--- /dev/null
+++ b/web/air/src/context/User/index.js
@@ -0,0 +1,19 @@
+// contexts/User/index.jsx
+
+import React from "react"
+import { reducer, initialState } from "./reducer"
+
+export const UserContext = React.createContext({
+ state: initialState,
+ dispatch: () => null
+})
+
+export const UserProvider = ({ children }) => {
+ const [state, dispatch] = React.useReducer(reducer, initialState)
+
+ return (
+
+ { children }
+
+ )
+}
\ No newline at end of file
diff --git a/web/air/src/context/User/reducer.js b/web/air/src/context/User/reducer.js
new file mode 100644
index 00000000..9ed1d809
--- /dev/null
+++ b/web/air/src/context/User/reducer.js
@@ -0,0 +1,21 @@
+export const reducer = (state, action) => {
+ switch (action.type) {
+ case 'login':
+ return {
+ ...state,
+ user: action.payload
+ };
+ case 'logout':
+ return {
+ ...state,
+ user: undefined
+ };
+
+ default:
+ return state;
+ }
+};
+
+export const initialState = {
+ user: undefined
+};
\ No newline at end of file
diff --git a/web/air/src/helpers/api.js b/web/air/src/helpers/api.js
new file mode 100644
index 00000000..35fdb1e9
--- /dev/null
+++ b/web/air/src/helpers/api.js
@@ -0,0 +1,13 @@
+import { showError } from './utils';
+import axios from 'axios';
+
+export const API = axios.create({
+ baseURL: process.env.REACT_APP_SERVER ? process.env.REACT_APP_SERVER : '',
+});
+
+API.interceptors.response.use(
+ (response) => response,
+ (error) => {
+ showError(error);
+ }
+);
diff --git a/web/air/src/helpers/auth-header.js b/web/air/src/helpers/auth-header.js
new file mode 100644
index 00000000..a8fe5f5a
--- /dev/null
+++ b/web/air/src/helpers/auth-header.js
@@ -0,0 +1,10 @@
+export function authHeader() {
+ // return authorization header with jwt token
+ let user = JSON.parse(localStorage.getItem('user'));
+
+ if (user && user.token) {
+ return { 'Authorization': 'Bearer ' + user.token };
+ } else {
+ return {};
+ }
+}
\ No newline at end of file
diff --git a/web/air/src/helpers/history.js b/web/air/src/helpers/history.js
new file mode 100644
index 00000000..629039e5
--- /dev/null
+++ b/web/air/src/helpers/history.js
@@ -0,0 +1,3 @@
+import { createBrowserHistory } from 'history';
+
+export const history = createBrowserHistory();
\ No newline at end of file
diff --git a/web/air/src/helpers/index.js b/web/air/src/helpers/index.js
new file mode 100644
index 00000000..505a8cf9
--- /dev/null
+++ b/web/air/src/helpers/index.js
@@ -0,0 +1,4 @@
+export * from './history';
+export * from './auth-header';
+export * from './utils';
+export * from './api';
\ No newline at end of file
diff --git a/web/air/src/helpers/render.js b/web/air/src/helpers/render.js
new file mode 100644
index 00000000..62fb0dcd
--- /dev/null
+++ b/web/air/src/helpers/render.js
@@ -0,0 +1,170 @@
+import {Label} from 'semantic-ui-react';
+import {Tag} from "@douyinfe/semi-ui";
+
+export function renderText(text, limit) {
+ if (text.length > limit) {
+ return text.slice(0, limit - 3) + '...';
+ }
+ return text;
+}
+
+export function renderGroup(group) {
+ if (group === '') {
+ return default;
+ }
+ let groups = group.split(',');
+ groups.sort();
+ return <>
+ {groups.map((group) => {
+ if (group === 'vip' || group === 'pro') {
+ return {group};
+ } else if (group === 'svip' || group === 'premium') {
+ return {group};
+ }
+ if (group === 'default') {
+ return {group};
+ } else {
+ return {group};
+ }
+ })}
+ >;
+}
+
+export function renderNumber(num) {
+ if (num >= 1000000000) {
+ return (num / 1000000000).toFixed(1) + 'B';
+ } else if (num >= 1000000) {
+ return (num / 1000000).toFixed(1) + 'M';
+ } else if (num >= 10000) {
+ return (num / 1000).toFixed(1) + 'k';
+ } else {
+ return num;
+ }
+}
+
+export function renderQuotaNumberWithDigit(num, digits = 2) {
+ let displayInCurrency = localStorage.getItem('display_in_currency');
+ num = num.toFixed(digits);
+ if (displayInCurrency) {
+ return '$' + num;
+ }
+ return num;
+}
+
+export function renderNumberWithPoint(num) {
+ num = num.toFixed(2);
+ if (num >= 100000) {
+ // Convert number to string to manipulate it
+ let numStr = num.toString();
+ // Find the position of the decimal point
+ let decimalPointIndex = numStr.indexOf('.');
+
+ let wholePart = numStr;
+ let decimalPart = '';
+
+ // If there is a decimal point, split the number into whole and decimal parts
+ if (decimalPointIndex !== -1) {
+ wholePart = numStr.slice(0, decimalPointIndex);
+ decimalPart = numStr.slice(decimalPointIndex);
+ }
+
+ // Take the first two and last two digits of the whole number part
+ let shortenedWholePart = wholePart.slice(0, 2) + '..' + wholePart.slice(-2);
+
+ // Return the formatted number
+ return shortenedWholePart + decimalPart;
+ }
+
+ // If the number is less than 100,000, return it unmodified
+ return num;
+}
+
+export function getQuotaPerUnit() {
+ let quotaPerUnit = localStorage.getItem('quota_per_unit');
+ quotaPerUnit = parseFloat(quotaPerUnit);
+ return quotaPerUnit;
+}
+
+export function getQuotaWithUnit(quota, digits = 6) {
+ let quotaPerUnit = localStorage.getItem('quota_per_unit');
+ quotaPerUnit = parseFloat(quotaPerUnit);
+ return (quota / quotaPerUnit).toFixed(digits);
+}
+
+export function renderQuota(quota, digits = 2) {
+ let quotaPerUnit = localStorage.getItem('quota_per_unit');
+ let displayInCurrency = localStorage.getItem('display_in_currency');
+ quotaPerUnit = parseFloat(quotaPerUnit);
+ displayInCurrency = displayInCurrency === 'true';
+ if (displayInCurrency) {
+ return '$' + (quota / quotaPerUnit).toFixed(digits);
+ }
+ return renderNumber(quota);
+}
+
+export function renderQuotaWithPrompt(quota, digits) {
+ let displayInCurrency = localStorage.getItem('display_in_currency');
+ displayInCurrency = displayInCurrency === 'true';
+ if (displayInCurrency) {
+ return `(等价金额:${renderQuota(quota, digits)})`;
+ }
+ return '';
+}
+
+const colors = ['amber', 'blue', 'cyan', 'green', 'grey', 'indigo',
+ 'light-blue', 'lime', 'orange', 'pink',
+ 'purple', 'red', 'teal', 'violet', 'yellow'
+]
+
+export const modelColorMap = {
+ 'dall-e': 'rgb(147,112,219)', // 深紫色
+ 'dall-e-2': 'rgb(147,112,219)', // 介于紫色和蓝色之间的色调
+ 'dall-e-3': 'rgb(153,50,204)', // 介于紫罗兰和洋红之间的色调
+ 'midjourney': 'rgb(136,43,180)', // 介于紫罗兰和洋红之间的色调
+ 'gpt-3.5-turbo': 'rgb(184,227,167)', // 浅绿色
+ 'gpt-3.5-turbo-0301': 'rgb(131,220,131)', // 亮绿色
+ 'gpt-3.5-turbo-0613': 'rgb(60,179,113)', // 海洋绿
+ 'gpt-3.5-turbo-1106': 'rgb(32,178,170)', // 浅海洋绿
+ 'gpt-3.5-turbo-16k': 'rgb(252,200,149)', // 淡橙色
+ 'gpt-3.5-turbo-16k-0613': 'rgb(255,181,119)', // 淡桃色
+ 'gpt-3.5-turbo-instruct': 'rgb(175,238,238)', // 粉蓝色
+ 'gpt-4': 'rgb(135,206,235)', // 天蓝色
+ 'gpt-4-0314': 'rgb(70,130,180)', // 钢蓝色
+ 'gpt-4-0613': 'rgb(100,149,237)', // 矢车菊蓝
+ 'gpt-4-1106-preview': 'rgb(30,144,255)', // 道奇蓝
+ 'gpt-4-0125-preview': 'rgb(2,177,236)', // 深天蓝
+ 'gpt-4-turbo-preview': 'rgb(2,177,255)', // 深天蓝
+ 'gpt-4-32k': 'rgb(104,111,238)', // 中紫色
+ 'gpt-4-32k-0314': 'rgb(90,105,205)', // 暗灰蓝色
+ 'gpt-4-32k-0613': 'rgb(61,71,139)', // 暗蓝灰色
+ 'gpt-4-all': 'rgb(65,105,225)', // 皇家蓝
+ 'gpt-4-gizmo-*': 'rgb(0,0,255)', // 纯蓝色
+ 'gpt-4-vision-preview': 'rgb(25,25,112)', // 午夜蓝
+ 'text-ada-001': 'rgb(255,192,203)', // 粉红色
+ 'text-babbage-001': 'rgb(255,160,122)', // 浅珊瑚色
+ 'text-curie-001': 'rgb(219,112,147)', // 苍紫罗兰色
+ 'text-davinci-002': 'rgb(199,21,133)', // 中紫罗兰红色
+ 'text-davinci-003': 'rgb(219,112,147)', // 苍紫罗兰色(与Curie相同,表示同一个系列)
+ 'text-davinci-edit-001': 'rgb(255,105,180)', // 热粉色
+ 'text-embedding-ada-002': 'rgb(255,182,193)', // 浅粉红
+ 'text-embedding-v1': 'rgb(255,174,185)', // 浅粉红色(略有区别)
+ 'text-moderation-latest': 'rgb(255,130,171)', // 强粉色
+ 'text-moderation-stable': 'rgb(255,160,122)', // 浅珊瑚色(与Babbage相同,表示同一类功能)
+ 'tts-1': 'rgb(255,140,0)', // 深橙色
+ 'tts-1-1106': 'rgb(255,165,0)', // 橙色
+ 'tts-1-hd': 'rgb(255,215,0)', // 金色
+ 'tts-1-hd-1106': 'rgb(255,223,0)', // 金黄色(略有区别)
+ 'whisper-1': 'rgb(245,245,220)' // 米色
+}
+
+export function stringToColor(str) {
+ let sum = 0;
+ // 对字符串中的每个字符进行操作
+ for (let i = 0; i < str.length; i++) {
+ // 将字符的ASCII值加到sum中
+ sum += str.charCodeAt(i);
+ }
+ // 使用模运算得到个位数
+ let i = sum % colors.length;
+ return colors[i];
+}
\ No newline at end of file
diff --git a/web/air/src/helpers/utils.js b/web/air/src/helpers/utils.js
new file mode 100644
index 00000000..3e1fdb20
--- /dev/null
+++ b/web/air/src/helpers/utils.js
@@ -0,0 +1,233 @@
+import { Toast } from '@douyinfe/semi-ui';
+import { toastConstants } from '../constants';
+import React from 'react';
+import {toast} from "react-toastify";
+
+const HTMLToastContent = ({ htmlContent }) => {
+ return ;
+};
+export default HTMLToastContent;
+export function isAdmin() {
+ let user = localStorage.getItem('user');
+ if (!user) return false;
+ user = JSON.parse(user);
+ return user.role >= 10;
+}
+
+export function isRoot() {
+ let user = localStorage.getItem('user');
+ if (!user) return false;
+ user = JSON.parse(user);
+ return user.role >= 100;
+}
+
+export function getSystemName() {
+ let system_name = localStorage.getItem('system_name');
+ if (!system_name) return 'New API';
+ return system_name;
+}
+
+export function getLogo() {
+ let logo = localStorage.getItem('logo');
+ if (!logo) return '/logo.png';
+ return logo
+}
+
+export function getFooterHTML() {
+ return localStorage.getItem('footer_html');
+}
+
+export async function copy(text) {
+ let okay = true;
+ try {
+ await navigator.clipboard.writeText(text);
+ } catch (e) {
+ okay = false;
+ console.error(e);
+ }
+ return okay;
+}
+
+export function isMobile() {
+ return window.innerWidth <= 600;
+}
+
+let showErrorOptions = { autoClose: toastConstants.ERROR_TIMEOUT };
+let showWarningOptions = { autoClose: toastConstants.WARNING_TIMEOUT };
+let showSuccessOptions = { autoClose: toastConstants.SUCCESS_TIMEOUT };
+let showInfoOptions = { autoClose: toastConstants.INFO_TIMEOUT };
+let showNoticeOptions = { autoClose: false };
+
+if (isMobile()) {
+ showErrorOptions.position = 'top-center';
+ // showErrorOptions.transition = 'flip';
+
+ showSuccessOptions.position = 'top-center';
+ // showSuccessOptions.transition = 'flip';
+
+ showInfoOptions.position = 'top-center';
+ // showInfoOptions.transition = 'flip';
+
+ showNoticeOptions.position = 'top-center';
+ // showNoticeOptions.transition = 'flip';
+}
+
+export function showError(error) {
+ console.error(error);
+ if (error.message) {
+ if (error.name === 'AxiosError') {
+ switch (error.response.status) {
+ case 401:
+ // toast.error('错误:未登录或登录已过期,请重新登录!', showErrorOptions);
+ window.location.href = '/login?expired=true';
+ break;
+ case 429:
+ Toast.error('错误:请求次数过多,请稍后再试!');
+ break;
+ case 500:
+ Toast.error('错误:服务器内部错误,请联系管理员!');
+ break;
+ case 405:
+ Toast.info('本站仅作演示之用,无服务端!');
+ break;
+ default:
+ Toast.error('错误:' + error.message);
+ }
+ return;
+ }
+ Toast.error('错误:' + error.message);
+ } else {
+ Toast.error('错误:' + error);
+ }
+}
+
+export function showWarning(message) {
+ Toast.warning(message);
+}
+
+export function showSuccess(message) {
+ Toast.success(message);
+}
+
+export function showInfo(message) {
+ Toast.info(message);
+}
+
+export function showNotice(message, isHTML = false) {
+ if (isHTML) {
+ toast(, showNoticeOptions);
+ } else {
+ Toast.info(message);
+ }
+}
+
+export function openPage(url) {
+ window.open(url);
+}
+
+export function removeTrailingSlash(url) {
+ if (url.endsWith('/')) {
+ return url.slice(0, -1);
+ } else {
+ return url;
+ }
+}
+
+export function timestamp2string(timestamp) {
+ let date = new Date(timestamp * 1000);
+ let year = date.getFullYear().toString();
+ let month = (date.getMonth() + 1).toString();
+ let day = date.getDate().toString();
+ let hour = date.getHours().toString();
+ let minute = date.getMinutes().toString();
+ let second = date.getSeconds().toString();
+ if (month.length === 1) {
+ month = '0' + month;
+ }
+ if (day.length === 1) {
+ day = '0' + day;
+ }
+ if (hour.length === 1) {
+ hour = '0' + hour;
+ }
+ if (minute.length === 1) {
+ minute = '0' + minute;
+ }
+ if (second.length === 1) {
+ second = '0' + second;
+ }
+ return (
+ year +
+ '-' +
+ month +
+ '-' +
+ day +
+ ' ' +
+ hour +
+ ':' +
+ minute +
+ ':' +
+ second
+ );
+}
+
+export function timestamp2string1(timestamp, dataExportDefaultTime = 'hour') {
+ let date = new Date(timestamp * 1000);
+ // let year = date.getFullYear().toString();
+ let month = (date.getMonth() + 1).toString();
+ let day = date.getDate().toString();
+ let hour = date.getHours().toString();
+ if (month.length === 1) {
+ month = '0' + month;
+ }
+ if (day.length === 1) {
+ day = '0' + day;
+ }
+ if (hour.length === 1) {
+ hour = '0' + hour;
+ }
+ let str = month + '-' + day
+ if (dataExportDefaultTime === 'hour') {
+ str += ' ' + hour + ":00"
+ } else if (dataExportDefaultTime === 'week') {
+ let nextWeek = new Date(timestamp * 1000 + 6 * 24 * 60 * 60 * 1000);
+ let nextMonth = (nextWeek.getMonth() + 1).toString();
+ let nextDay = nextWeek.getDate().toString();
+ if (nextMonth.length === 1) {
+ nextMonth = '0' + nextMonth;
+ }
+ if (nextDay.length === 1) {
+ nextDay = '0' + nextDay;
+ }
+ str += ' - ' + nextMonth + '-' + nextDay
+ }
+ return str;
+}
+
+export function downloadTextAsFile(text, filename) {
+ let blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
+ let url = URL.createObjectURL(blob);
+ let a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ a.click();
+}
+
+export const verifyJSON = (str) => {
+ try {
+ JSON.parse(str);
+ } catch (e) {
+ return false;
+ }
+ return true;
+};
+
+export function shouldShowPrompt(id) {
+ let prompt = localStorage.getItem(`prompt-${id}`);
+ return !prompt;
+
+}
+
+export function setPromptShown(id) {
+ localStorage.setItem(`prompt-${id}`, 'true');
+}
\ No newline at end of file
diff --git a/web/air/src/index.css b/web/air/src/index.css
new file mode 100644
index 00000000..8e53624e
--- /dev/null
+++ b/web/air/src/index.css
@@ -0,0 +1,105 @@
+body {
+ margin: 0;
+ padding-top: 55px;
+ overflow-y: scroll;
+ font-family: Lato, 'Helvetica Neue', Arial, Helvetica, "Microsoft YaHei", sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ scrollbar-width: none;
+ color: var(--semi-color-text-0) !important;
+ background-color: var( --semi-color-bg-0) !important;
+ height: 100%;
+}
+
+#root {
+ height: 100%;
+}
+
+@media only screen and (max-width: 767px) {
+ .semi-table-tbody, .semi-table-row, .semi-table-row-cell {
+ display: block!important;
+ width: auto!important;
+ padding: 2px!important;
+ }
+ .semi-table-row-cell {
+ border-bottom: 0!important;
+ }
+ .semi-table-tbody>.semi-table-row {
+ border-bottom: 1px solid rgba(0,0,0,.1);
+ }
+ .semi-space {
+ /*display: block!important;*/
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ row-gap: 3px;
+ column-gap: 10px;
+ }
+}
+
+.semi-table-tbody > .semi-table-row > .semi-table-row-cell {
+ padding: 16px 14px;
+}
+
+.channel-table {
+ .semi-table-tbody > .semi-table-row > .semi-table-row-cell {
+ padding: 16px 8px;
+ }
+}
+
+.semi-layout {
+ height: 100%;
+}
+
+.tableShow {
+ display: revert;
+}
+
+.tableHiddle {
+ display: none !important;
+}
+
+body::-webkit-scrollbar {
+ display: none;
+}
+
+code {
+ font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
+}
+
+.semi-navigation-vertical {
+ /*display: flex;*/
+ /*flex-direction: column;*/
+}
+
+.semi-navigation-item {
+ margin-bottom: 0;
+}
+
+.semi-navigation-vertical {
+ /*flex: 0 0 auto;*/
+ /*display: flex;*/
+ /*flex-direction: column;*/
+ /*width: 100%;*/
+ height: 100%;
+ overflow: hidden;
+}
+
+.main-content {
+ padding: 4px;
+ height: 100%;
+}
+
+.small-icon .icon {
+ font-size: 1em !important;
+}
+
+.custom-footer {
+ font-size: 1.1em;
+}
+
+@media only screen and (max-width: 600px) {
+ .hide-on-mobile {
+ display: none !important;
+ }
+}
diff --git a/web/air/src/index.js b/web/air/src/index.js
new file mode 100644
index 00000000..25b1d39e
--- /dev/null
+++ b/web/air/src/index.js
@@ -0,0 +1,54 @@
+import { initVChartSemiTheme } from '@visactor/vchart-semi-theme';
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import {BrowserRouter} from 'react-router-dom';
+import App from './App';
+import HeaderBar from './components/HeaderBar';
+import Footer from './components/Footer';
+import 'semantic-ui-css/semantic.min.css';
+import './index.css';
+import {UserProvider} from './context/User';
+import {ToastContainer} from 'react-toastify';
+import 'react-toastify/dist/ReactToastify.css';
+import {StatusProvider} from './context/Status';
+import {Layout} from "@douyinfe/semi-ui";
+import SiderBar from "./components/SiderBar";
+
+// initialization
+initVChartSemiTheme({
+ isWatchingThemeSwitch: true,
+});
+
+const root = ReactDOM.createRoot(document.getElementById('root'));
+const {Sider, Content, Header} = Layout;
+root.render(
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
diff --git a/web/air/src/pages/About/index.js b/web/air/src/pages/About/index.js
new file mode 100644
index 00000000..ec13f151
--- /dev/null
+++ b/web/air/src/pages/About/index.js
@@ -0,0 +1,58 @@
+import React, { useEffect, useState } from 'react';
+import { Header, Segment } from 'semantic-ui-react';
+import { API, showError } from '../../helpers';
+import { marked } from 'marked';
+
+const About = () => {
+ const [about, setAbout] = useState('');
+ const [aboutLoaded, setAboutLoaded] = useState(false);
+
+ const displayAbout = async () => {
+ setAbout(localStorage.getItem('about') || '');
+ const res = await API.get('/api/about');
+ const { success, message, data } = res.data;
+ if (success) {
+ let aboutContent = data;
+ if (!data.startsWith('https://')) {
+ aboutContent = marked.parse(data);
+ }
+ setAbout(aboutContent);
+ localStorage.setItem('about', aboutContent);
+ } else {
+ showError(message);
+ setAbout('加载关于内容失败...');
+ }
+ setAboutLoaded(true);
+ };
+
+ useEffect(() => {
+ displayAbout().then();
+ }, []);
+
+ return (
+ <>
+ {
+ aboutLoaded && about === '' ? <>
+
+
+ 可在设置页面设置关于内容,支持 HTML & Markdown
+ 项目仓库地址:
+
+ https://github.com/songquanpeng/one-api
+
+
+ > : <>
+ {
+ about.startsWith('https://') ? :
+ }
+ >
+ }
+ >
+ );
+};
+
+
+export default About;
diff --git a/web/air/src/pages/Channel/EditChannel.js b/web/air/src/pages/Channel/EditChannel.js
new file mode 100644
index 00000000..2b84011b
--- /dev/null
+++ b/web/air/src/pages/Channel/EditChannel.js
@@ -0,0 +1,628 @@
+import React, {useEffect, useRef, useState} from 'react';
+import {useNavigate, useParams} from 'react-router-dom';
+import {API, isMobile, showError, showInfo, showSuccess, verifyJSON} from '../../helpers';
+import {CHANNEL_OPTIONS} from '../../constants';
+import Title from "@douyinfe/semi-ui/lib/es/typography/title";
+import {SideSheet, Space, Spin, Button, Input, Typography, Select, TextArea, Checkbox, Banner} from "@douyinfe/semi-ui";
+
+const MODEL_MAPPING_EXAMPLE = {
+ 'gpt-3.5-turbo-0301': 'gpt-3.5-turbo',
+ 'gpt-4-0314': 'gpt-4',
+ 'gpt-4-32k-0314': 'gpt-4-32k'
+};
+
+function type2secretPrompt(type) {
+ // inputs.type === 15 ? '按照如下格式输入:APIKey|SecretKey' : (inputs.type === 18 ? '按照如下格式输入:APPID|APISecret|APIKey' : '请输入渠道对应的鉴权密钥')
+ switch (type) {
+ case 15:
+ return '按照如下格式输入:APIKey|SecretKey';
+ case 18:
+ return '按照如下格式输入:APPID|APISecret|APIKey';
+ case 22:
+ return '按照如下格式输入:APIKey-AppId,例如:fastgpt-0sp2gtvfdgyi4k30jwlgwf1i-64f335d84283f05518e9e041';
+ case 23:
+ return '按照如下格式输入:AppId|SecretId|SecretKey';
+ default:
+ return '请输入渠道对应的鉴权密钥';
+ }
+}
+
+const EditChannel = (props) => {
+ const navigate = useNavigate();
+ const channelId = props.editingChannel.id;
+ const isEdit = channelId !== undefined;
+ const [loading, setLoading] = useState(isEdit);
+ const handleCancel = () => {
+ props.handleClose()
+ };
+ const originInputs = {
+ name: '',
+ type: 1,
+ key: '',
+ openai_organization: '',
+ base_url: '',
+ other: '',
+ model_mapping: '',
+ models: [],
+ auto_ban: 1,
+ groups: ['default']
+ };
+ const [batch, setBatch] = useState(false);
+ const [autoBan, setAutoBan] = useState(true);
+ // const [autoBan, setAutoBan] = useState(true);
+ const [inputs, setInputs] = useState(originInputs);
+ const [originModelOptions, setOriginModelOptions] = useState([]);
+ const [modelOptions, setModelOptions] = useState([]);
+ const [groupOptions, setGroupOptions] = useState([]);
+ const [basicModels, setBasicModels] = useState([]);
+ const [fullModels, setFullModels] = useState([]);
+ const [customModel, setCustomModel] = useState('');
+ const handleInputChange = (name, value) => {
+ setInputs((inputs) => ({...inputs, [name]: value}));
+ if (name === 'type' && inputs.models.length === 0) {
+ let localModels = [];
+ switch (value) {
+ case 14:
+ localModels = ["claude-instant-1.2", "claude-2", "claude-2.0", "claude-2.1", "claude-3-opus-20240229", "claude-3-sonnet-20240229", "claude-3-haiku-20240307"];
+ break;
+ case 11:
+ localModels = ['PaLM-2'];
+ break;
+ case 15:
+ localModels = ['ERNIE-Bot', 'ERNIE-Bot-turbo', 'ERNIE-Bot-4', 'Embedding-V1'];
+ break;
+ case 17:
+ localModels = ["qwen-turbo", "qwen-plus", "qwen-max", "qwen-max-longcontext", 'text-embedding-v1'];
+ break;
+ case 16:
+ localModels = ['chatglm_pro', 'chatglm_std', 'chatglm_lite'];
+ break;
+ case 18:
+ localModels = ['SparkDesk', 'SparkDesk-v1.1', 'SparkDesk-v2.1', 'SparkDesk-v3.1', 'SparkDesk-v3.5'];
+ break;
+ case 19:
+ localModels = ['360GPT_S2_V9', 'embedding-bert-512-v1', 'embedding_s1_v1', 'semantic_similarity_s1_v1'];
+ break;
+ case 23:
+ localModels = ['hunyuan'];
+ break;
+ case 24:
+ localModels = ['gemini-pro', 'gemini-pro-vision'];
+ break;
+ case 25:
+ localModels = ['moonshot-v1-8k', 'moonshot-v1-32k', 'moonshot-v1-128k'];
+ break;
+ case 26:
+ localModels = ['glm-4', 'glm-4v', 'glm-3-turbo'];
+ break;
+ case 2:
+ localModels = ['mj_imagine', 'mj_variation', 'mj_reroll', 'mj_blend', 'mj_upscale', 'mj_describe'];
+ break;
+ case 5:
+ localModels = [
+ 'swap_face',
+ 'mj_imagine',
+ 'mj_variation',
+ 'mj_reroll',
+ 'mj_blend',
+ 'mj_upscale',
+ 'mj_describe',
+ 'mj_zoom',
+ 'mj_shorten',
+ 'mj_modal',
+ 'mj_inpaint',
+ 'mj_custom_zoom',
+ 'mj_high_variation',
+ 'mj_low_variation',
+ 'mj_pan',
+ ];
+ break;
+ }
+ setInputs((inputs) => ({...inputs, models: localModels}));
+ }
+ //setAutoBan
+ };
+
+
+ const loadChannel = async () => {
+ setLoading(true)
+ let res = await API.get(`/api/channel/${channelId}`);
+ const {success, message, data} = res.data;
+ if (success) {
+ if (data.models === '') {
+ data.models = [];
+ } else {
+ data.models = data.models.split(',');
+ }
+ if (data.group === '') {
+ data.groups = [];
+ } else {
+ data.groups = data.group.split(',');
+ }
+ if (data.model_mapping !== '') {
+ data.model_mapping = JSON.stringify(JSON.parse(data.model_mapping), null, 2);
+ }
+ setInputs(data);
+ if (data.auto_ban === 0) {
+ setAutoBan(false);
+ } else {
+ setAutoBan(true);
+ }
+ // console.log(data);
+ } else {
+ showError(message);
+ }
+ setLoading(false);
+ };
+
+ const fetchModels = async () => {
+ try {
+ let res = await API.get(`/api/channel/models`);
+ let localModelOptions = res.data.data.map((model) => ({
+ label: model.id,
+ value: model.id
+ }));
+ setOriginModelOptions(localModelOptions);
+ setFullModels(res.data.data.map((model) => model.id));
+ setBasicModels(res.data.data.filter((model) => {
+ return model.id.startsWith('gpt-3') || model.id.startsWith('text-');
+ }).map((model) => model.id));
+ } catch (error) {
+ showError(error.message);
+ }
+ };
+
+ const fetchGroups = async () => {
+ try {
+ let res = await API.get(`/api/group/`);
+ setGroupOptions(res.data.data.map((group) => ({
+ label: group,
+ value: group
+ })));
+ } catch (error) {
+ showError(error.message);
+ }
+ };
+
+ useEffect(() => {
+ let localModelOptions = [...originModelOptions];
+ inputs.models.forEach((model) => {
+ if (!localModelOptions.find((option) => option.key === model)) {
+ localModelOptions.push({
+ label: model,
+ value: model
+ });
+ }
+ });
+ setModelOptions(localModelOptions);
+ }, [originModelOptions, inputs.models]);
+
+ useEffect(() => {
+ fetchModels().then();
+ fetchGroups().then();
+ if (isEdit) {
+ loadChannel().then(
+ () => {
+
+ }
+ );
+ } else {
+ setInputs(originInputs)
+ }
+ }, [props.editingChannel.id]);
+
+
+ const submit = async () => {
+ if (!isEdit && (inputs.name === '' || inputs.key === '')) {
+ showInfo('请填写渠道名称和渠道密钥!');
+ return;
+ }
+ if (inputs.models.length === 0) {
+ showInfo('请至少选择一个模型!');
+ return;
+ }
+ if (inputs.model_mapping !== '' && !verifyJSON(inputs.model_mapping)) {
+ showInfo('模型映射必须是合法的 JSON 格式!');
+ return;
+ }
+ let localInputs = {...inputs};
+ if (localInputs.base_url && localInputs.base_url.endsWith('/')) {
+ localInputs.base_url = localInputs.base_url.slice(0, localInputs.base_url.length - 1);
+ }
+ if (localInputs.type === 3 && localInputs.other === '') {
+ localInputs.other = '2023-06-01-preview';
+ }
+ if (localInputs.type === 18 && localInputs.other === '') {
+ localInputs.other = 'v2.1';
+ }
+ let res;
+ if (!Array.isArray(localInputs.models)) {
+ showError('提交失败,请勿重复提交!');
+ handleCancel();
+ return;
+ }
+ localInputs.auto_ban = autoBan ? 1 : 0;
+ localInputs.models = localInputs.models.join(',');
+ localInputs.group = localInputs.groups.join(',');
+ if (isEdit) {
+ res = await API.put(`/api/channel/`, {...localInputs, id: parseInt(channelId)});
+ } else {
+ res = await API.post(`/api/channel/`, localInputs);
+ }
+ const {success, message} = res.data;
+ if (success) {
+ if (isEdit) {
+ showSuccess('渠道更新成功!');
+ } else {
+ showSuccess('渠道创建成功!');
+ setInputs(originInputs);
+ }
+ props.refresh();
+ props.handleClose();
+ } else {
+ showError(message);
+ }
+ };
+
+ const addCustomModel = () => {
+ if (customModel.trim() === '') return;
+ if (inputs.models.includes(customModel)) return showError("该模型已存在!");
+ let localModels = [...inputs.models];
+ localModels.push(customModel);
+ let localModelOptions = [];
+ localModelOptions.push({
+ key: customModel,
+ text: customModel,
+ value: customModel
+ });
+ setModelOptions(modelOptions => {
+ return [...modelOptions, ...localModelOptions];
+ });
+ setCustomModel('');
+ handleInputChange('models', localModels);
+ };
+
+ return (
+ <>
+ {isEdit ? '更新渠道信息' : '创建新的渠道'}}
+ headerStyle={{borderBottom: '1px solid var(--semi-color-border)'}}
+ bodyStyle={{borderBottom: '1px solid var(--semi-color-border)'}}
+ visible={props.visible}
+ footer={
+
+
+
+
+
+
+ }
+ closeIcon={null}
+ onCancel={() => handleCancel()}
+ width={isMobile() ? '100%' : 600}
+ >
+
+
+ 类型:
+
+
+
+ >
+ );
+};
+
+export default EditChannel;
diff --git a/web/air/src/pages/Channel/index.js b/web/air/src/pages/Channel/index.js
new file mode 100644
index 00000000..402a9b1d
--- /dev/null
+++ b/web/air/src/pages/Channel/index.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import ChannelsTable from '../../components/ChannelsTable';
+import {Layout} from "@douyinfe/semi-ui";
+
+const File = () => (
+ <>
+
+
+ 管理渠道
+
+
+
+
+
+ >
+);
+
+export default File;
diff --git a/web/air/src/pages/Chat/index.js b/web/air/src/pages/Chat/index.js
new file mode 100644
index 00000000..dbc49204
--- /dev/null
+++ b/web/air/src/pages/Chat/index.js
@@ -0,0 +1,15 @@
+import React from 'react';
+
+const Chat = () => {
+ const chatLink = localStorage.getItem('chat_link');
+
+ return (
+
+ );
+};
+
+
+export default Chat;
diff --git a/web/air/src/pages/Detail/index.js b/web/air/src/pages/Detail/index.js
new file mode 100644
index 00000000..d2373cbc
--- /dev/null
+++ b/web/air/src/pages/Detail/index.js
@@ -0,0 +1,359 @@
+import React, {useEffect, useRef, useState} from 'react';
+import {Button, Col, Form, Layout, Row, Spin} from "@douyinfe/semi-ui";
+import VChart from '@visactor/vchart';
+import {API, isAdmin, showError, timestamp2string, timestamp2string1} from "../../helpers";
+import {
+ getQuotaWithUnit, modelColorMap,
+ renderNumber,
+ renderQuota,
+ renderQuotaNumberWithDigit,
+ stringToColor
+} from "../../helpers/render";
+
+const Detail = (props) => {
+ const formRef = useRef();
+ let now = new Date();
+ const [inputs, setInputs] = useState({
+ username: '',
+ token_name: '',
+ model_name: '',
+ start_timestamp: localStorage.getItem('data_export_default_time') === 'hour' ? timestamp2string(now.getTime() / 1000 - 86400) : (localStorage.getItem('data_export_default_time') === 'week' ? timestamp2string(now.getTime() / 1000 - 86400 * 30) : timestamp2string(now.getTime() / 1000 - 86400 * 7)),
+ end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),
+ channel: '',
+ data_export_default_time: ''
+ });
+ const {username, model_name, start_timestamp, end_timestamp, channel} = inputs;
+ const isAdminUser = isAdmin();
+ const initialized = useRef(false)
+ const [modelDataChart, setModelDataChart] = useState(null);
+ const [modelDataPieChart, setModelDataPieChart] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [quotaData, setQuotaData] = useState([]);
+ const [consumeQuota, setConsumeQuota] = useState(0);
+ const [times, setTimes] = useState(0);
+ const [dataExportDefaultTime, setDataExportDefaultTime] = useState(localStorage.getItem('data_export_default_time') || 'hour');
+
+ const handleInputChange = (value, name) => {
+ if (name === 'data_export_default_time') {
+ setDataExportDefaultTime(value);
+ return
+ }
+ setInputs((inputs) => ({...inputs, [name]: value}));
+ };
+
+ const spec_line = {
+ type: 'bar',
+ data: [
+ {
+ id: 'barData',
+ values: []
+ }
+ ],
+ xField: 'Time',
+ yField: 'Usage',
+ seriesField: 'Model',
+ stack: true,
+ legends: {
+ visible: true
+ },
+ title: {
+ visible: true,
+ text: '模型消耗分布',
+ subtext: '0'
+ },
+ bar: {
+ // The state style of bar
+ state: {
+ hover: {
+ stroke: '#000',
+ lineWidth: 1
+ }
+ }
+ },
+ tooltip: {
+ mark: {
+ content: [
+ {
+ key: datum => datum['Model'],
+ value: datum => renderQuotaNumberWithDigit(parseFloat(datum['Usage']), 4)
+ }
+ ]
+ },
+ dimension: {
+ content: [
+ {
+ key: datum => datum['Model'],
+ value: datum => datum['Usage']
+ }
+ ],
+ updateContent: array => {
+ // sort by value
+ array.sort((a, b) => b.value - a.value);
+ // add $
+ let sum = 0;
+ for (let i = 0; i < array.length; i++) {
+ sum += parseFloat(array[i].value);
+ array[i].value = renderQuotaNumberWithDigit(parseFloat(array[i].value), 4);
+ }
+ // add to first
+ array.unshift({
+ key: '总计',
+ value: renderQuotaNumberWithDigit(sum, 4)
+ });
+ return array;
+ }
+ }
+ },
+ color: {
+ specified: modelColorMap
+ }
+ };
+
+ const spec_pie = {
+ type: 'pie',
+ data: [
+ {
+ id: 'id0',
+ values: [
+ {type: 'null', value: '0'},
+ ]
+ }
+ ],
+ outerRadius: 0.8,
+ innerRadius: 0.5,
+ padAngle: 0.6,
+ valueField: 'value',
+ categoryField: 'type',
+ pie: {
+ style: {
+ cornerRadius: 10
+ },
+ state: {
+ hover: {
+ outerRadius: 0.85,
+ stroke: '#000',
+ lineWidth: 1
+ },
+ selected: {
+ outerRadius: 0.85,
+ stroke: '#000',
+ lineWidth: 1
+ }
+ }
+ },
+ title: {
+ visible: true,
+ text: '模型调用次数占比'
+ },
+ legends: {
+ visible: true,
+ orient: 'left'
+ },
+ label: {
+ visible: true
+ },
+ tooltip: {
+ mark: {
+ content: [
+ {
+ key: datum => datum['type'],
+ value: datum => renderNumber(datum['value'])
+ }
+ ]
+ }
+ },
+ color: {
+ specified: modelColorMap
+ }
+ };
+
+ const loadQuotaData = async (lineChart, pieChart) => {
+ setLoading(true);
+
+ let url = '';
+ let localStartTimestamp = Date.parse(start_timestamp) / 1000;
+ let localEndTimestamp = Date.parse(end_timestamp) / 1000;
+ if (isAdminUser) {
+ url = `/api/data/?username=${username}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;
+ } else {
+ url = `/api/data/self/?start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;
+ }
+ const res = await API.get(url);
+ const {success, message, data} = res.data;
+ if (success) {
+ setQuotaData(data);
+ if (data.length === 0) {
+ data.push({
+ 'count': 0,
+ 'model_name': '无数据',
+ 'quota': 0,
+ 'created_at': now.getTime() / 1000
+ })
+ }
+ // 根据dataExportDefaultTime重制时间粒度
+ let timeGranularity = 3600;
+ if (dataExportDefaultTime === 'day') {
+ timeGranularity = 86400;
+ } else if (dataExportDefaultTime === 'week') {
+ timeGranularity = 604800;
+ }
+ data.forEach(item => {
+ item['created_at'] = Math.floor(item['created_at'] / timeGranularity) * timeGranularity;
+ });
+ updateChart(lineChart, pieChart, data);
+ } else {
+ showError(message);
+ }
+ setLoading(false);
+ };
+
+ const refresh = async () => {
+ await loadQuotaData(modelDataChart, modelDataPieChart);
+ };
+
+ const initChart = async () => {
+ let lineChart = modelDataChart
+ if (!modelDataChart) {
+ lineChart = new VChart(spec_line, {dom: 'model_data'});
+ setModelDataChart(lineChart);
+ lineChart.renderAsync();
+ }
+ let pieChart = modelDataPieChart
+ if (!modelDataPieChart) {
+ pieChart = new VChart(spec_pie, {dom: 'model_pie'});
+ setModelDataPieChart(pieChart);
+ pieChart.renderAsync();
+ }
+ console.log('init vchart');
+ await loadQuotaData(lineChart, pieChart)
+ }
+
+ const updateChart = (lineChart, pieChart, data) => {
+ if (isAdminUser) {
+ // 将所有用户合并
+ }
+ let pieData = [];
+ let lineData = [];
+ let consumeQuota = 0;
+ let times = 0;
+ for (let i = 0; i < data.length; i++) {
+ const item = data[i];
+ consumeQuota += item.quota;
+ times += item.count;
+ // 合并model_name
+ let pieItem = pieData.find(it => it.type === item.model_name);
+ if (pieItem) {
+ pieItem.value += item.count;
+ } else {
+ pieData.push({
+ "type": item.model_name,
+ "value": item.count
+ });
+ }
+ // 合并created_at和model_name 为 lineData, created_at 数据类型是小时的时间戳
+ // 转换日期格式
+ let createTime = timestamp2string1(item.created_at, dataExportDefaultTime);
+ let lineItem = lineData.find(it => it.Time === createTime && it.Model === item.model_name);
+ if (lineItem) {
+ lineItem.Usage += parseFloat(getQuotaWithUnit(item.quota));
+ } else {
+ lineData.push({
+ "Time": createTime,
+ "Model": item.model_name,
+ "Usage": parseFloat(getQuotaWithUnit(item.quota))
+ });
+ }
+ }
+ setConsumeQuota(consumeQuota);
+ setTimes(times);
+
+ // sort by count
+ pieData.sort((a, b) => b.value - a.value);
+ spec_pie.title.subtext = `总计:${renderNumber(times)}`;
+ spec_pie.data[0].values = pieData;
+
+ spec_line.title.subtext = `总计:${renderQuota(consumeQuota, 2)}`;
+ spec_line.data[0].values = lineData;
+ pieChart.updateSpec(spec_pie);
+ lineChart.updateSpec(spec_line);
+
+ // pieChart.updateData('id0', pieData);
+ // lineChart.updateData('barData', lineData);
+ pieChart.reLayout();
+ lineChart.reLayout();
+ }
+
+ useEffect(() => {
+ // setDataExportDefaultTime(localStorage.getItem('data_export_default_time'));
+ // if (dataExportDefaultTime === 'day') {
+ // // 设置开始时间为7天前
+ // let st = timestamp2string(now.getTime() / 1000 - 86400 * 7)
+ // inputs.start_timestamp = st;
+ // formRef.current.formApi.setValue('start_timestamp', st);
+ // }
+ if (!initialized.current) {
+ initialized.current = true;
+ initChart();
+ }
+ }, []);
+
+ return (
+ <>
+
+
+ 数据看板
+
+
+ handleInputChange(value, 'start_timestamp')}/>
+ handleInputChange(value, 'end_timestamp')}/>
+ handleInputChange(value, 'data_export_default_time')}>
+
+ {
+ isAdminUser && <>
+ handleInputChange(value, 'username')}/>
+ >
+ }
+
+
+
+ >
+
+
+
+
+
+
+
+ >
+ );
+};
+
+
+export default Detail;
diff --git a/web/air/src/pages/Home/index.js b/web/air/src/pages/Home/index.js
new file mode 100644
index 00000000..4803ba4e
--- /dev/null
+++ b/web/air/src/pages/Home/index.js
@@ -0,0 +1,130 @@
+import React, { useContext, useEffect, useState } from 'react';
+import { Card, Col, Row } from '@douyinfe/semi-ui';
+import { API, showError, showNotice, timestamp2string } from '../../helpers';
+import { StatusContext } from '../../context/Status';
+import { marked } from 'marked';
+
+const Home = () => {
+ const [statusState] = useContext(StatusContext);
+ const [homePageContentLoaded, setHomePageContentLoaded] = useState(false);
+ const [homePageContent, setHomePageContent] = useState('');
+
+ const displayNotice = async () => {
+ const res = await API.get('/api/notice');
+ const { success, message, data } = res.data;
+ if (success) {
+ let oldNotice = localStorage.getItem('notice');
+ if (data !== oldNotice && data !== '') {
+ const htmlNotice = marked(data);
+ showNotice(htmlNotice, true);
+ localStorage.setItem('notice', data);
+ }
+ } else {
+ showError(message);
+ }
+ };
+
+ const displayHomePageContent = async () => {
+ setHomePageContent(localStorage.getItem('home_page_content') || '');
+ const res = await API.get('/api/home_page_content');
+ const { success, message, data } = res.data;
+ if (success) {
+ let content = data;
+ if (!data.startsWith('https://')) {
+ content = marked.parse(data);
+ }
+ setHomePageContent(content);
+ localStorage.setItem('home_page_content', content);
+ } else {
+ showError(message);
+ setHomePageContent('加载首页内容失败...');
+ }
+ setHomePageContentLoaded(true);
+ };
+
+ const getStartTimeString = () => {
+ const timestamp = statusState?.status?.start_time;
+ return statusState.status ? timestamp2string(timestamp) : '';
+ };
+
+ useEffect(() => {
+ displayNotice().then();
+ displayHomePageContent().then();
+ }, []);
+ return (
+ <>
+ {
+ homePageContentLoaded && homePageContent === '' ?
+ <>
+
+
+
+ 系统信息总览}>
+ 名称:{statusState?.status?.system_name}
+ 版本:{statusState?.status?.version ? statusState?.status?.version : 'unknown'}
+
+ 源码:
+
+ https://github.com/songquanpeng/one-api
+
+
+ 启动时间:{getStartTimeString()}
+
+
+
+ 系统配置总览}>
+
+ 邮箱验证:
+ {statusState?.status?.email_verification === true ? '已启用' : '未启用'}
+
+
+ GitHub 身份验证:
+ {statusState?.status?.github_oauth === true ? '已启用' : '未启用'}
+
+
+ 微信身份验证:
+ {statusState?.status?.wechat_login === true ? '已启用' : '未启用'}
+
+
+ Turnstile 用户校验:
+ {statusState?.status?.turnstile_check === true ? '已启用' : '未启用'}
+
+ {/**/}
+ {/* Telegram 身份验证:*/}
+ {/* {statusState?.status?.telegram_oauth === true*/}
+ {/* ? '已启用' : '未启用'}*/}
+ {/*
*/}
+
+
+
+
+
+ >
+ : <>
+ {
+ homePageContent.startsWith('https://') ?
+ :
+
+ }
+ >
+ }
+
+ >
+ );
+};
+
+export default Home;
\ No newline at end of file
diff --git a/web/air/src/pages/Log/index.js b/web/air/src/pages/Log/index.js
new file mode 100644
index 00000000..4ee109ca
--- /dev/null
+++ b/web/air/src/pages/Log/index.js
@@ -0,0 +1,10 @@
+import React from 'react';
+import LogsTable from '../../components/LogsTable';
+
+const Token = () => (
+ <>
+
+ >
+);
+
+export default Token;
diff --git a/web/air/src/pages/Midjourney/index.js b/web/air/src/pages/Midjourney/index.js
new file mode 100644
index 00000000..ed22ecd0
--- /dev/null
+++ b/web/air/src/pages/Midjourney/index.js
@@ -0,0 +1,10 @@
+import React from 'react';
+import MjLogsTable from '../../components/MjLogsTable';
+
+const Midjourney = () => (
+ <>
+
+ >
+);
+
+export default Midjourney;
diff --git a/web/air/src/pages/NotFound/index.js b/web/air/src/pages/NotFound/index.js
new file mode 100644
index 00000000..f92dbc90
--- /dev/null
+++ b/web/air/src/pages/NotFound/index.js
@@ -0,0 +1,13 @@
+import React from 'react';
+import { Message } from 'semantic-ui-react';
+
+const NotFound = () => (
+ <>
+
+ 页面不存在
+ 请检查你的浏览器地址是否正确
+
+ >
+);
+
+export default NotFound;
diff --git a/web/air/src/pages/Redemption/EditRedemption.js b/web/air/src/pages/Redemption/EditRedemption.js
new file mode 100644
index 00000000..be62fb22
--- /dev/null
+++ b/web/air/src/pages/Redemption/EditRedemption.js
@@ -0,0 +1,181 @@
+import React, { useEffect, useState } from '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 originInputs = {
+ name: '',
+ quota: 100000,
+ count: 1
+ };
+ const [inputs, setInputs] = useState(originInputs);
+ const { name, quota, count } = inputs;
+
+ const handleCancel = () => {
+ props.handleClose();
+ };
+
+ const handleInputChange = (name, value) => {
+ setInputs((inputs) => ({ ...inputs, [name]: value }));
+ };
+
+ const loadRedemption = async () => {
+ setLoading(true);
+ let res = await API.get(`/api/redemption/${props.editingRedemption.id}`);
+ const { success, message, data } = res.data;
+ if (success) {
+ setInputs(data);
+ } else {
+ showError(message);
+ }
+ setLoading(false);
+ };
+
+ useEffect(() => {
+ if (isEdit) {
+ loadRedemption().then(
+ () => {
+ // console.log(inputs);
+ }
+ );
+ } else {
+ setInputs(originInputs);
+ }
+ }, [props.editingRedemption.id]);
+
+ const submit = async () => {
+ if (!isEdit && inputs.name === '') return;
+ setLoading(true);
+ let localInputs = inputs;
+ localInputs.count = parseInt(localInputs.count);
+ localInputs.quota = parseInt(localInputs.quota);
+ let res;
+ if (isEdit) {
+ res = await API.put(`/api/redemption/`, { ...localInputs, id: parseInt(props.editingRedemption.id) });
+ } else {
+ res = await API.post(`/api/redemption/`, {
+ ...localInputs
+ });
+ }
+ const { success, message, data } = res.data;
+ if (success) {
+ if (isEdit) {
+ showSuccess('兑换码更新成功!');
+ props.refresh();
+ props.handleClose();
+ } else {
+ showSuccess('兑换码创建成功!');
+ setInputs(originInputs);
+ props.refresh();
+ props.handleClose();
+ }
+ } else {
+ showError(message);
+ }
+ if (!isEdit && data) {
+ let text = '';
+ for (let i = 0; i < data.length; i++) {
+ text += data[i] + '\n';
+ }
+ // downloadTextAsFile(text, `${inputs.name}.txt`);
+ Modal.confirm({
+ title: '兑换码创建成功',
+ content: (
+
+
兑换码创建成功,是否下载兑换码?
+
兑换码将以文本文件的形式下载,文件名为兑换码的名称。
+
+ ),
+ onOk: () => {
+ downloadTextAsFile(text, `${inputs.name}.txt`);
+ }
+ });
+ }
+ setLoading(false);
+ };
+
+ return (
+ <>
+ {isEdit ? '更新兑换码信息' : '创建新的兑换码'}}
+ headerStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
+ bodyStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
+ visible={props.visiable}
+ footer={
+
+
+
+
+
+
+ }
+ closeIcon={null}
+ onCancel={() => handleCancel()}
+ width={isMobile() ? '100%' : 600}
+ >
+
+ handleInputChange('name', value)}
+ value={name}
+ autoComplete="new-password"
+ required={!isEdit}
+ />
+
+
+ {`额度${renderQuotaWithPrompt(quota)}`}
+
+ 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 && <>
+
+ 生成数量
+ handleInputChange('count', value)}
+ value={count}
+ autoComplete="new-password"
+ type="number"
+ />
+ >
+ }
+
+
+ >
+ );
+};
+
+export default EditRedemption;
diff --git a/web/air/src/pages/Redemption/index.js b/web/air/src/pages/Redemption/index.js
new file mode 100644
index 00000000..534428db
--- /dev/null
+++ b/web/air/src/pages/Redemption/index.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import RedemptionsTable from '../../components/RedemptionsTable';
+import {Layout} from "@douyinfe/semi-ui";
+
+const Redemption = () => (
+ <>
+
+
+ 管理兑换码
+
+
+
+
+
+ >
+);
+
+export default Redemption;
diff --git a/web/air/src/pages/Setting/index.js b/web/air/src/pages/Setting/index.js
new file mode 100644
index 00000000..99b3e4c2
--- /dev/null
+++ b/web/air/src/pages/Setting/index.js
@@ -0,0 +1,53 @@
+import React from 'react';
+import SystemSetting from '../../components/SystemSetting';
+import {isRoot} from '../../helpers';
+import OtherSetting from '../../components/OtherSetting';
+import PersonalSetting from '../../components/PersonalSetting';
+import OperationSetting from '../../components/OperationSetting';
+import {Layout, TabPane, Tabs} from "@douyinfe/semi-ui";
+
+const Setting = () => {
+ let panes = [
+ {
+ tab: '个人设置',
+ content: ,
+ itemKey: '1'
+ }
+ ];
+
+ if (isRoot()) {
+ panes.push({
+ tab: '运营设置',
+ content: ,
+ itemKey: '2'
+ });
+ panes.push({
+ tab: '系统设置',
+ content: ,
+ itemKey: '3'
+ });
+ panes.push({
+ tab: '其他设置',
+ content: ,
+ itemKey: '4'
+ });
+ }
+
+ return (
+
+
+
+
+ {panes.map(pane => (
+
+ {pane.content}
+
+ ))}
+
+
+
+
+ );
+};
+
+export default Setting;
diff --git a/web/air/src/pages/Token/EditToken.js b/web/air/src/pages/Token/EditToken.js
new file mode 100644
index 00000000..0f533065
--- /dev/null
+++ b/web/air/src/pages/Token/EditToken.js
@@ -0,0 +1,351 @@
+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 {
+ AutoComplete,
+ Banner,
+ Button,
+ Checkbox,
+ DatePicker,
+ Input,
+ Select,
+ SideSheet,
+ Space,
+ Spin,
+ Typography
+} from '@douyinfe/semi-ui';
+import Title from '@douyinfe/semi-ui/lib/es/typography/title';
+import { Divider } from 'semantic-ui-react';
+
+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 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 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);
+ }
+ setLoading(false);
+ };
+ useEffect(() => {
+ setIsEdit(props.editingToken.id !== undefined);
+ }, [props.editingToken.id]);
+
+ useEffect(() => {
+ if (!isEdit) {
+ setInputs(originInputs);
+ } else {
+ loadToken().then(
+ () => {
+ // console.log(inputs);
+ }
+ );
+ }
+ // loadModels();
+ }, [isEdit]);
+
+ // 新增 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;
+ };
+
+ 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);
+
+ 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();
+ }
+ }
+ setLoading(false);
+ setInputs(originInputs); // 重置表单
+ setTokenCount(1); // 重置数量为默认值
+ };
+
+
+ return (
+ <>
+ {isEdit ? '更新令牌信息' : '创建新的令牌'}}
+ headerStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
+ bodyStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
+ visible={props.visiable}
+ footer={
+
+
+
+
+
+
+ }
+ closeIcon={null}
+ onCancel={() => handleCancel()}
+ width={isMobile() ? '100%' : 600}
+ >
+
+ handleInputChange('name', value)}
+ value={name}
+ autoComplete="new-password"
+ required={!isEdit}
+ />
+
+ handleInputChange('expired_time', value)}
+ value={expired_time}
+ autoComplete="new-password"
+ type="dateTime"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ {`额度${renderQuotaWithPrompt(remain_quota)}`}
+
+ 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 && (
+ <>
+
+ 新建数量
+
+ 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}
+ />
+ >
+ )}
+
+
+
+
+ {/*
+
+
+ handleInputChange('model_limits_enabled', e.target.checked)}
+ >
+
+ 启用模型限制(非必要,不建议启用)
+
+
+
+ {
+ handleInputChange('model_limits', value);
+ }}
+ value={inputs.model_limits}
+ autoComplete="new-password"
+ optionList={models}
+ disabled={!model_limits_enabled}
+ /> */}
+
+
+ >
+ );
+};
+
+export default EditToken;
diff --git a/web/air/src/pages/Token/index.js b/web/air/src/pages/Token/index.js
new file mode 100644
index 00000000..eaa1f4be
--- /dev/null
+++ b/web/air/src/pages/Token/index.js
@@ -0,0 +1,17 @@
+import React from 'react';
+import TokensTable from '../../components/TokensTable';
+import {Layout} from "@douyinfe/semi-ui";
+const Token = () => (
+ <>
+
+
+ 我的令牌
+
+
+
+
+
+ >
+);
+
+export default Token;
diff --git a/web/air/src/pages/TopUp/index.js b/web/air/src/pages/TopUp/index.js
new file mode 100644
index 00000000..49b891cc
--- /dev/null
+++ b/web/air/src/pages/TopUp/index.js
@@ -0,0 +1,314 @@
+import React, {useEffect, useState} from 'react';
+import {API, isMobile, showError, showInfo, showSuccess} from '../../helpers';
+import {renderNumber, renderQuota} from '../../helpers/render';
+import {Col, Layout, Row, Typography, Card, Button, Form, Divider, Space, Modal} from "@douyinfe/semi-ui";
+import Title from "@douyinfe/semi-ui/lib/es/typography/title";
+import Text from '@douyinfe/semi-ui/lib/es/typography/text';
+import { Link } from 'react-router-dom';
+
+const TopUp = () => {
+ const [redemptionCode, setRedemptionCode] = useState('');
+ const [topUpCode, setTopUpCode] = useState('');
+ const [topUpCount, setTopUpCount] = useState(10);
+ const [minTopupCount, setMinTopUpCount] = useState(1);
+ const [amount, setAmount] = useState(0.0);
+ const [minTopUp, setMinTopUp] = useState(1);
+ const [topUpLink, setTopUpLink] = useState('');
+ const [enableOnlineTopUp, setEnableOnlineTopUp] = useState(false);
+ const [userQuota, setUserQuota] = useState(0);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [open, setOpen] = useState(false);
+ const [payWay, setPayWay] = useState('');
+
+ const topUp = async () => {
+ if (redemptionCode === '') {
+ showInfo('请输入兑换码!')
+ return;
+ }
+ setIsSubmitting(true);
+ try {
+ const res = await API.post('/api/user/topup', {
+ key: redemptionCode
+ });
+ const {success, message, data} = res.data;
+ if (success) {
+ showSuccess('兑换成功!');
+ Modal.success({title: '兑换成功!', content: '成功兑换额度:' + renderQuota(data), centered: true});
+ setUserQuota((quota) => {
+ return quota + data;
+ });
+ setRedemptionCode('');
+ } else {
+ showError(message);
+ }
+ } catch (err) {
+ showError('请求失败');
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const openTopUpLink = () => {
+ if (!topUpLink) {
+ showError('超级管理员未设置充值链接!');
+ return;
+ }
+ window.open(topUpLink, '_blank');
+ };
+
+ const preTopUp = async (payment) => {
+ if (!enableOnlineTopUp) {
+ showError('管理员未开启在线充值!');
+ return;
+ }
+ if (amount === 0) {
+ await getAmount();
+ }
+ if (topUpCount < minTopUp) {
+ showInfo('充值数量不能小于' + minTopUp);
+ return;
+ }
+ setPayWay(payment)
+ setOpen(true);
+ }
+
+ const onlineTopUp = async () => {
+ if (amount === 0) {
+ await getAmount();
+ }
+ if (topUpCount < minTopUp) {
+ showInfo('充值数量不能小于' + minTopUp);
+ return;
+ }
+ setOpen(false);
+ try {
+ const res = await API.post('/api/user/pay', {
+ amount: parseInt(topUpCount),
+ top_up_code: topUpCode,
+ payment_method: payWay
+ });
+ if (res !== undefined) {
+ const {message, data} = res.data;
+ // showInfo(message);
+ if (message === 'success') {
+
+ let params = data
+ let url = res.data.url
+ let form = document.createElement('form')
+ form.action = url
+ form.method = 'POST'
+ // 判断是否为safari浏览器
+ let isSafari = navigator.userAgent.indexOf("Safari") > -1 && navigator.userAgent.indexOf("Chrome") < 1;
+ if (!isSafari) {
+ form.target = '_blank'
+ }
+ for (let key in params) {
+ let input = document.createElement('input')
+ input.type = 'hidden'
+ input.name = key
+ input.value = params[key]
+ form.appendChild(input)
+ }
+ document.body.appendChild(form)
+ form.submit()
+ document.body.removeChild(form)
+ } else {
+ showError(data);
+ // setTopUpCount(parseInt(res.data.count));
+ // setAmount(parseInt(data));
+ }
+ } else {
+ showError(res);
+ }
+ } catch (err) {
+ console.log(err);
+ } finally {
+ }
+ }
+
+ const getUserQuota = async () => {
+ let res = await API.get(`/api/user/self`);
+ const {success, message, data} = res.data;
+ if (success) {
+ setUserQuota(data.quota);
+ } else {
+ showError(message);
+ }
+ }
+
+ useEffect(() => {
+ let status = localStorage.getItem('status');
+ if (status) {
+ status = JSON.parse(status);
+ if (status.top_up_link) {
+ setTopUpLink(status.top_up_link);
+ }
+ if (status.min_topup) {
+ setMinTopUp(status.min_topup);
+ }
+ if (status.enable_online_topup) {
+ setEnableOnlineTopUp(status.enable_online_topup);
+ }
+ }
+ getUserQuota().then();
+ }, []);
+
+ const renderAmount = () => {
+ // console.log(amount);
+ return amount + '元';
+ }
+
+ const getAmount = async (value) => {
+ if (value === undefined) {
+ value = topUpCount;
+ }
+ try {
+ const res = await API.post('/api/user/amount', {
+ amount: parseFloat(value),
+ top_up_code: topUpCode
+ });
+ if (res !== undefined) {
+ const {message, data} = res.data;
+ // showInfo(message);
+ if (message === 'success') {
+ setAmount(parseFloat(data));
+ } else {
+ showError(data);
+ // setTopUpCount(parseInt(res.data.count));
+ // setAmount(parseInt(data));
+ }
+ } else {
+ showError(res);
+ }
+ } catch (err) {
+ console.log(err);
+ } finally {
+ }
+ }
+
+ const handleCancel = () => {
+ setOpen(false);
+ }
+
+ return (
+
+
+
+ 充值额度
+
+
+
+ 充值数量:{topUpCount}$
+ 实付金额:{renderAmount()}
+ 是否确认充值?
+
+
+
+ 余额 {renderQuota(userQuota)}
+
+
+ 兑换余额
+
+
{
+ setRedemptionCode(value);
+ }}
+ />
+
+ {
+ topUpLink ?
+ : null
+ }
+
+
+
+
+ {/*
+
+ 在线充值
+
+
{
+ if (value < 1) {
+ value = 1;
+ }
+ if (value > 100000) {
+ value = 100000;
+ }
+ setTopUpCount(value);
+ await getAmount(value);
+ }}
+ />
+
+
+
+
+
+ */}
+ {/**/}
+ {/* */}
+ {/* {*/}
+ {/* window.location.href = '/topup/history'*/}
+ {/* }*/}
+ {/* }>充值记录*/}
+ {/* */}
+ {/*
*/}
+
+
+
+
+
+
+
+ );
+};
+
+export default TopUp;
\ No newline at end of file
diff --git a/web/air/src/pages/User/AddUser.js b/web/air/src/pages/User/AddUser.js
new file mode 100644
index 00000000..7ebdd330
--- /dev/null
+++ b/web/air/src/pages/User/AddUser.js
@@ -0,0 +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, 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 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();
+ };
+
+ return (
+ <>
+ {'添加用户'}}
+ headerStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
+ bodyStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
+ visible={props.visible}
+ footer={
+
+
+
+
+
+
+ }
+ closeIcon={null}
+ onCancel={() => handleCancel()}
+ width={isMobile() ? '100%' : 600}
+ >
+
+ handleInputChange('username', value)}
+ value={username}
+ autoComplete="off"
+ />
+ handleInputChange('display_name', value)}
+ value={display_name}
+ />
+ handleInputChange('password', value)}
+ value={password}
+ autoComplete="off"
+ />
+
+
+ >
+ );
+};
+
+export default AddUser;
diff --git a/web/air/src/pages/User/EditUser.js b/web/air/src/pages/User/EditUser.js
new file mode 100644
index 00000000..1a354269
--- /dev/null
+++ b/web/air/src/pages/User/EditUser.js
@@ -0,0 +1,220 @@
+import React, { useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { API, isMobile, showError, showSuccess } from '../../helpers';
+import { renderQuotaWithPrompt } from '../../helpers/render';
+import Title from '@douyinfe/semi-ui/lib/es/typography/title';
+import { Button, Divider, Input, Select, SideSheet, Space, Spin, Typography } from '@douyinfe/semi-ui';
+
+const EditUser = (props) => {
+ const userId = props.editingUser.id;
+ const [loading, setLoading] = useState(true);
+ const [inputs, setInputs] = useState({
+ username: '',
+ display_name: '',
+ password: '',
+ github_id: '',
+ wechat_id: '',
+ email: '',
+ quota: 0,
+ group: 'default'
+ });
+ const [groupOptions, setGroupOptions] = useState([]);
+ const { username, display_name, password, github_id, wechat_id, telegram_id, email, quota, group } =
+ inputs;
+ const handleInputChange = (name, value) => {
+ setInputs((inputs) => ({ ...inputs, [name]: value }));
+ };
+ const fetchGroups = async () => {
+ try {
+ let res = await API.get(`/api/group/`);
+ setGroupOptions(res.data.data.map((group) => ({
+ label: group,
+ value: group
+ })));
+ } catch (error) {
+ showError(error.message);
+ }
+ };
+ const navigate = useNavigate();
+ const handleCancel = () => {
+ props.handleClose();
+ };
+ const loadUser = async () => {
+ setLoading(true);
+ let res = undefined;
+ if (userId) {
+ res = await API.get(`/api/user/${userId}`);
+ } else {
+ res = await API.get(`/api/user/self`);
+ }
+ const { success, message, data } = res.data;
+ if (success) {
+ data.password = '';
+ setInputs(data);
+ } else {
+ showError(message);
+ }
+ setLoading(false);
+ };
+
+ useEffect(() => {
+ loadUser().then();
+ if (userId) {
+ fetchGroups().then();
+ }
+ }, [props.editingUser.id]);
+
+ const submit = async () => {
+ setLoading(true);
+ let res = undefined;
+ if (userId) {
+ let data = { ...inputs, id: parseInt(userId) };
+ if (typeof data.quota === 'string') {
+ data.quota = parseInt(data.quota);
+ }
+ res = await API.put(`/api/user/`, data);
+ } else {
+ res = await API.put(`/api/user/self`, inputs);
+ }
+ const { success, message } = res.data;
+ if (success) {
+ showSuccess('用户信息更新成功!');
+ props.refresh();
+ props.handleClose();
+ } else {
+ showError(message);
+ }
+ setLoading(false);
+ };
+
+ return (
+ <>
+ {'编辑用户'}}
+ headerStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
+ bodyStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
+ visible={props.visible}
+ footer={
+
+
+
+
+
+
+ }
+ closeIcon={null}
+ onCancel={() => handleCancel()}
+ width={isMobile() ? '100%' : 600}
+ >
+
+
+ 用户名
+
+ handleInputChange('username', value)}
+ value={username}
+ autoComplete="new-password"
+ />
+
+ 密码
+
+ handleInputChange('password', value)}
+ value={password}
+ autoComplete="new-password"
+ />
+
+ 显示名称
+
+ handleInputChange('display_name', value)}
+ value={display_name}
+ autoComplete="new-password"
+ />
+ {
+ userId && <>
+
+ 分组
+
+ handleInputChange('group', value)}
+ value={inputs.group}
+ autoComplete="new-password"
+ optionList={groupOptions}
+ />
+
+ {`剩余额度${renderQuotaWithPrompt(quota)}`}
+
+ handleInputChange('quota', value)}
+ value={quota}
+ type={'number'}
+ autoComplete="new-password"
+ />
+ >
+ }
+ 以下信息不可修改
+
+ 已绑定的 GitHub 账户
+
+
+
+ 已绑定的微信账户
+
+
+
+
+ 已绑定的邮箱账户
+
+
+
+
+ >
+ );
+};
+
+export default EditUser;
diff --git a/web/air/src/pages/User/index.js b/web/air/src/pages/User/index.js
new file mode 100644
index 00000000..b5e2ca74
--- /dev/null
+++ b/web/air/src/pages/User/index.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import UsersTable from '../../components/UsersTable';
+import {Layout} from "@douyinfe/semi-ui";
+
+const User = () => (
+ <>
+
+
+ 管理用户
+
+
+
+
+
+ >
+);
+
+export default User;
diff --git a/web/air/vercel.json b/web/air/vercel.json
new file mode 100644
index 00000000..7ae9a3de
--- /dev/null
+++ b/web/air/vercel.json
@@ -0,0 +1,5 @@
+{
+ "github": {
+ "silent": true
+ }
+}