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 @@ |![image](https://github.com/songquanpeng/one-api/assets/42402987/fb2b1c64-ef24-4027-9b80-0cd9d945a47f)|![image](https://github.com/songquanpeng/one-api/assets/42402987/b6b649ec-2888-4324-8b2d-d5e11554eed6)| |![image](https://github.com/songquanpeng/one-api/assets/42402987/6d3b22e0-436b-4e26-8911-bcc993c6a2bd)|![image](https://github.com/songquanpeng/one-api/assets/42402987/eef1e224-7245-44d7-804e-9d1c8fa3f29c)| +### 主题:air +由 [Calon](https://github.com/Calcium-Ion) 开发。 +|![image](https://github.com/songquanpeng/songquanpeng.github.io/assets/39998050/1ddb274b-a715-4e81-858b-857d520b6ff4)|![image](https://github.com/songquanpeng/songquanpeng.github.io/assets/39998050/163b0b8e-1f73-49cb-b632-3dcb986b56d5)| +|:---:|:---:| + + #### 开发说明 请查看 [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 ( + <> + +
+
{ + searchChannels(searchKeyword, searchGroup, searchModel); + }} labelPosition="left"> +
+ + { + setSearchKeyword(v.trim()); + }} + /> + {/* { + setSearchModel(v.trim()); + }} + /> + { + setSearchGroup(v); + searchChannels(searchKeyword, v, searchModel); + }} /> */} + + +
+
+
+ + + { 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 ? ( +
+ ) : ( +
+ + {systemName} {process.env.REACT_APP_VERSION}{' '} + + 由{' '} + + JustSong + {' '} + 构建,主题 air 来自{' '} + + Calon + {' '},源代码遵循{' '} + + MIT 协议 + +
+ )} +
+
+ ); +}; + +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 ? ( +
+ + ) : ( + <> + )} + 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 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 ( + + +
+ 密码重置 +
+
+ + + {turnstileEnabled ? ( + { + setTurnstileToken(token); + }} + /> + ) : ( + <> + )} + + + +
+
+ ); +}; + +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 ( + + +
+ 新用户注册 +
+
+ + + + + {showEmailVerification ? ( + <> + + 获取验证码 + + } + /> + + + ) : ( + <> + )} + {turnstileEnabled ? ( + { + setTurnstileToken(token); + }} + /> + ) : ( + <> + )} + + +
+ + 已有账户? + + 点击登录 + + +
+
+ ); +}; + +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://') ?