feat: add new theme air (#1167)

* chore: add theme air with new-api main branch v0.2.0.3-alpha.1(first step)

* feat: 完成渠道界面

* chore: 优化渠道界面样式问题

* feat: 完成兑换码界面

* feat: 完成充值(钱包)界面

* chore: 初代air主题将使用default主题的运营设置界面、系统设置界面、其他设置界面

* feat: 完成日志界面

* feat: 完成用户管理界面

* feat: 完成个人设置界面

* feat: 完成令牌界面

* chore: 优化令牌界面逻辑

* feat: 修改版权信息

* chore: make necessary changes

---------

Co-authored-by: Calon <1808837298@qq.com>
Co-authored-by: Apple\Apple <zeraturing@foxmail.com>
Co-authored-by: JustSong <songquanpeng@foxmail.com>
This commit is contained in:
GAI Group
2024-03-16 15:27:52 +08:00
committed by JustSong
parent 205aba694f
commit 11af81eb39
70 changed files with 9648 additions and 0 deletions

View File

@@ -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 === '' ? <>
<Segment>
<Header as='h3'>关于</Header>
<p>可在设置页面设置关于内容支持 HTML & Markdown</p>
项目仓库地址
<a href='https://github.com/songquanpeng/one-api'>
https://github.com/songquanpeng/one-api
</a>
</Segment>
</> : <>
{
about.startsWith('https://') ? <iframe
src={about}
style={{ width: '100%', height: '100vh', border: 'none' }}
/> : <div style={{ fontSize: 'larger' }} dangerouslySetInnerHTML={{ __html: about }}></div>
}
</>
}
</>
);
};
export default About;

View File

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

View File

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

View File

@@ -0,0 +1,15 @@
import React from 'react';
const Chat = () => {
const chatLink = localStorage.getItem('chat_link');
return (
<iframe
src={chatLink}
style={{ width: '100%', height: '85vh', border: 'none' }}
/>
);
};
export default Chat;

View File

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

View File

@@ -0,0 +1,130 @@
import React, { useContext, useEffect, useState } from 'react';
import { Card, Col, Row } from '@douyinfe/semi-ui';
import { API, showError, showNotice, timestamp2string } from '../../helpers';
import { StatusContext } from '../../context/Status';
import { marked } from 'marked';
const Home = () => {
const [statusState] = useContext(StatusContext);
const [homePageContentLoaded, setHomePageContentLoaded] = useState(false);
const [homePageContent, setHomePageContent] = useState('');
const displayNotice = async () => {
const res = await API.get('/api/notice');
const { success, message, data } = res.data;
if (success) {
let oldNotice = localStorage.getItem('notice');
if (data !== oldNotice && data !== '') {
const htmlNotice = marked(data);
showNotice(htmlNotice, true);
localStorage.setItem('notice', data);
}
} else {
showError(message);
}
};
const displayHomePageContent = async () => {
setHomePageContent(localStorage.getItem('home_page_content') || '');
const res = await API.get('/api/home_page_content');
const { success, message, data } = res.data;
if (success) {
let content = data;
if (!data.startsWith('https://')) {
content = marked.parse(data);
}
setHomePageContent(content);
localStorage.setItem('home_page_content', content);
} else {
showError(message);
setHomePageContent('加载首页内容失败...');
}
setHomePageContentLoaded(true);
};
const getStartTimeString = () => {
const timestamp = statusState?.status?.start_time;
return statusState.status ? timestamp2string(timestamp) : '';
};
useEffect(() => {
displayNotice().then();
displayHomePageContent().then();
}, []);
return (
<>
{
homePageContentLoaded && homePageContent === '' ?
<>
<Card
bordered={false}
headerLine={false}
title='系统状况'
bodyStyle={{ padding: '10px 20px' }}
>
<Row gutter={16}>
<Col span={12}>
<Card
title='系统信息'
headerExtraContent={<span
style={{ fontSize: '12px', color: 'var(--semi-color-text-1)' }}>系统信息总览</span>}>
<p>名称{statusState?.status?.system_name}</p>
<p>版本{statusState?.status?.version ? statusState?.status?.version : 'unknown'}</p>
<p>
源码
<a
href='https://github.com/songquanpeng/one-api'
target='_blank' rel='noreferrer'
>
https://github.com/songquanpeng/one-api
</a>
</p>
<p>启动时间{getStartTimeString()}</p>
</Card>
</Col>
<Col span={12}>
<Card
title='系统配置'
headerExtraContent={<span
style={{ fontSize: '12px', color: 'var(--semi-color-text-1)' }}>系统配置总览</span>}>
<p>
邮箱验证
{statusState?.status?.email_verification === true ? '已启用' : '未启用'}
</p>
<p>
GitHub 身份验证
{statusState?.status?.github_oauth === true ? '已启用' : '未启用'}
</p>
<p>
微信身份验证
{statusState?.status?.wechat_login === true ? '已启用' : '未启用'}
</p>
<p>
Turnstile 用户校验
{statusState?.status?.turnstile_check === true ? '已启用' : '未启用'}
</p>
{/*<p>*/}
{/* Telegram 身份验证:*/}
{/* {statusState?.status?.telegram_oauth === true*/}
{/* ? '已启用' : '未启用'}*/}
{/*</p>*/}
</Card>
</Col>
</Row>
</Card>
</>
: <>
{
homePageContent.startsWith('https://') ?
<iframe src={homePageContent} style={{ width: '100%', height: '100vh', border: 'none' }} /> :
<div style={{ fontSize: 'larger' }} dangerouslySetInnerHTML={{ __html: homePageContent }}></div>
}
</>
}
</>
);
};
export default Home;

View File

@@ -0,0 +1,10 @@
import React from 'react';
import LogsTable from '../../components/LogsTable';
const Token = () => (
<>
<LogsTable />
</>
);
export default Token;

View File

@@ -0,0 +1,10 @@
import React from 'react';
import MjLogsTable from '../../components/MjLogsTable';
const Midjourney = () => (
<>
<MjLogsTable />
</>
);
export default Midjourney;

View File

@@ -0,0 +1,13 @@
import React from 'react';
import { Message } from 'semantic-ui-react';
const NotFound = () => (
<>
<Message negative>
<Message.Header>页面不存在</Message.Header>
<p>请检查你的浏览器地址是否正确</p>
</Message>
</>
);
export default NotFound;

View File

@@ -0,0 +1,181 @@
import React, { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { API, downloadTextAsFile, isMobile, showError, showSuccess } from '../../helpers';
import { renderQuotaWithPrompt } from '../../helpers/render';
import { AutoComplete, Button, Input, Modal, SideSheet, Space, Spin, Typography } from '@douyinfe/semi-ui';
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
import { Divider } from 'semantic-ui-react';
const EditRedemption = (props) => {
const isEdit = props.editingRedemption.id !== undefined;
const [loading, setLoading] = useState(isEdit);
const params = useParams();
const navigate = useNavigate();
const originInputs = {
name: '',
quota: 100000,
count: 1
};
const [inputs, setInputs] = useState(originInputs);
const { name, quota, count } = inputs;
const handleCancel = () => {
props.handleClose();
};
const handleInputChange = (name, value) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
const loadRedemption = async () => {
setLoading(true);
let res = await API.get(`/api/redemption/${props.editingRedemption.id}`);
const { success, message, data } = res.data;
if (success) {
setInputs(data);
} else {
showError(message);
}
setLoading(false);
};
useEffect(() => {
if (isEdit) {
loadRedemption().then(
() => {
// console.log(inputs);
}
);
} else {
setInputs(originInputs);
}
}, [props.editingRedemption.id]);
const submit = async () => {
if (!isEdit && inputs.name === '') return;
setLoading(true);
let localInputs = inputs;
localInputs.count = parseInt(localInputs.count);
localInputs.quota = parseInt(localInputs.quota);
let res;
if (isEdit) {
res = await API.put(`/api/redemption/`, { ...localInputs, id: parseInt(props.editingRedemption.id) });
} else {
res = await API.post(`/api/redemption/`, {
...localInputs
});
}
const { success, message, data } = res.data;
if (success) {
if (isEdit) {
showSuccess('兑换码更新成功!');
props.refresh();
props.handleClose();
} else {
showSuccess('兑换码创建成功!');
setInputs(originInputs);
props.refresh();
props.handleClose();
}
} else {
showError(message);
}
if (!isEdit && data) {
let text = '';
for (let i = 0; i < data.length; i++) {
text += data[i] + '\n';
}
// downloadTextAsFile(text, `${inputs.name}.txt`);
Modal.confirm({
title: '兑换码创建成功',
content: (
<div>
<p>兑换码创建成功是否下载兑换码</p>
<p>兑换码将以文本文件的形式下载文件名为兑换码的名称</p>
</div>
),
onOk: () => {
downloadTextAsFile(text, `${inputs.name}.txt`);
}
});
}
setLoading(false);
};
return (
<>
<SideSheet
placement={isEdit ? 'right' : 'left'}
title={<Title level={3}>{isEdit ? '更新兑换码信息' : '创建新的兑换码'}</Title>}
headerStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
bodyStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
visible={props.visiable}
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Space>
<Button theme="solid" size={'large'} onClick={submit}>提交</Button>
<Button theme="solid" size={'large'} type={'tertiary'} onClick={handleCancel}>取消</Button>
</Space>
</div>
}
closeIcon={null}
onCancel={() => handleCancel()}
width={isMobile() ? '100%' : 600}
>
<Spin spinning={loading}>
<Input
style={{ marginTop: 20 }}
label="名称"
name="name"
placeholder={'请输入名称'}
onChange={value => handleInputChange('name', value)}
value={name}
autoComplete="new-password"
required={!isEdit}
/>
<Divider />
<div style={{ marginTop: 20 }}>
<Typography.Text>{`额度${renderQuotaWithPrompt(quota)}`}</Typography.Text>
</div>
<AutoComplete
style={{ marginTop: 8 }}
name="quota"
placeholder={'请输入额度'}
onChange={(value) => handleInputChange('quota', value)}
value={quota}
autoComplete="new-password"
type="number"
position={'bottom'}
data={[
{ value: 500000, label: '1$' },
{ value: 5000000, label: '10$' },
{ value: 25000000, label: '50$' },
{ value: 50000000, label: '100$' },
{ value: 250000000, label: '500$' },
{ value: 500000000, label: '1000$' }
]}
/>
{
!isEdit && <>
<Divider />
<Typography.Text>生成数量</Typography.Text>
<Input
style={{ marginTop: 8 }}
label="生成数量"
name="count"
placeholder={'请输入生成数量'}
onChange={value => handleInputChange('count', value)}
value={count}
autoComplete="new-password"
type="number"
/>
</>
}
</Spin>
</SideSheet>
</>
);
};
export default EditRedemption;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,98 @@
import React, { useState } from 'react';
import { API, isMobile, showError, showSuccess } from '../../helpers';
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
import { Button, Input, SideSheet, Space, Spin } from '@douyinfe/semi-ui';
const AddUser = (props) => {
const originInputs = {
username: '',
display_name: '',
password: ''
};
const [inputs, setInputs] = useState(originInputs);
const [loading, setLoading] = useState(false);
const { username, display_name, password } = inputs;
const handleInputChange = (name, value) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
const submit = async () => {
setLoading(true);
if (inputs.username === '' || inputs.password === '') return;
const res = await API.post(`/api/user/`, inputs);
const { success, message } = res.data;
if (success) {
showSuccess('用户账户创建成功!');
setInputs(originInputs);
props.refresh();
props.handleClose();
} else {
showError(message);
}
setLoading(false);
};
const handleCancel = () => {
props.handleClose();
};
return (
<>
<SideSheet
placement={'left'}
title={<Title level={3}>{'添加用户'}</Title>}
headerStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
bodyStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
visible={props.visible}
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Space>
<Button theme="solid" size={'large'} onClick={submit}>提交</Button>
<Button theme="solid" size={'large'} type={'tertiary'} onClick={handleCancel}>取消</Button>
</Space>
</div>
}
closeIcon={null}
onCancel={() => handleCancel()}
width={isMobile() ? '100%' : 600}
>
<Spin spinning={loading}>
<Input
style={{ marginTop: 20 }}
label="用户名"
name="username"
addonBefore={'用户名'}
placeholder={'请输入用户名'}
onChange={value => handleInputChange('username', value)}
value={username}
autoComplete="off"
/>
<Input
style={{ marginTop: 20 }}
addonBefore={'显示名'}
label="显示名称"
name="display_name"
autoComplete="off"
placeholder={'请输入显示名称'}
onChange={value => handleInputChange('display_name', value)}
value={display_name}
/>
<Input
style={{ marginTop: 20 }}
label="密 码"
name="password"
type={'password'}
addonBefore={'密码'}
placeholder={'请输入密码'}
onChange={value => handleInputChange('password', value)}
value={password}
autoComplete="off"
/>
</Spin>
</SideSheet>
</>
);
};
export default AddUser;

View File

@@ -0,0 +1,220 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { API, isMobile, showError, showSuccess } from '../../helpers';
import { renderQuotaWithPrompt } from '../../helpers/render';
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
import { Button, Divider, Input, Select, SideSheet, Space, Spin, Typography } from '@douyinfe/semi-ui';
const EditUser = (props) => {
const userId = props.editingUser.id;
const [loading, setLoading] = useState(true);
const [inputs, setInputs] = useState({
username: '',
display_name: '',
password: '',
github_id: '',
wechat_id: '',
email: '',
quota: 0,
group: 'default'
});
const [groupOptions, setGroupOptions] = useState([]);
const { username, display_name, password, github_id, wechat_id, telegram_id, email, quota, group } =
inputs;
const handleInputChange = (name, value) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
const fetchGroups = async () => {
try {
let res = await API.get(`/api/group/`);
setGroupOptions(res.data.data.map((group) => ({
label: group,
value: group
})));
} catch (error) {
showError(error.message);
}
};
const navigate = useNavigate();
const handleCancel = () => {
props.handleClose();
};
const loadUser = async () => {
setLoading(true);
let res = undefined;
if (userId) {
res = await API.get(`/api/user/${userId}`);
} else {
res = await API.get(`/api/user/self`);
}
const { success, message, data } = res.data;
if (success) {
data.password = '';
setInputs(data);
} else {
showError(message);
}
setLoading(false);
};
useEffect(() => {
loadUser().then();
if (userId) {
fetchGroups().then();
}
}, [props.editingUser.id]);
const submit = async () => {
setLoading(true);
let res = undefined;
if (userId) {
let data = { ...inputs, id: parseInt(userId) };
if (typeof data.quota === 'string') {
data.quota = parseInt(data.quota);
}
res = await API.put(`/api/user/`, data);
} else {
res = await API.put(`/api/user/self`, inputs);
}
const { success, message } = res.data;
if (success) {
showSuccess('用户信息更新成功!');
props.refresh();
props.handleClose();
} else {
showError(message);
}
setLoading(false);
};
return (
<>
<SideSheet
placement={'right'}
title={<Title level={3}>{'编辑用户'}</Title>}
headerStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
bodyStyle={{ borderBottom: '1px solid var(--semi-color-border)' }}
visible={props.visible}
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Space>
<Button theme="solid" size={'large'} onClick={submit}>提交</Button>
<Button theme="solid" size={'large'} type={'tertiary'} onClick={handleCancel}>取消</Button>
</Space>
</div>
}
closeIcon={null}
onCancel={() => handleCancel()}
width={isMobile() ? '100%' : 600}
>
<Spin spinning={loading}>
<div style={{ marginTop: 20 }}>
<Typography.Text>用户名</Typography.Text>
</div>
<Input
label="用户名"
name="username"
placeholder={'请输入新的用户名'}
onChange={value => handleInputChange('username', value)}
value={username}
autoComplete="new-password"
/>
<div style={{ marginTop: 20 }}>
<Typography.Text>密码</Typography.Text>
</div>
<Input
label="密码"
name="password"
type={'password'}
placeholder={'请输入新的密码,最短 8 位'}
onChange={value => handleInputChange('password', value)}
value={password}
autoComplete="new-password"
/>
<div style={{ marginTop: 20 }}>
<Typography.Text>显示名称</Typography.Text>
</div>
<Input
label="显示名称"
name="display_name"
placeholder={'请输入新的显示名称'}
onChange={value => handleInputChange('display_name', value)}
value={display_name}
autoComplete="new-password"
/>
{
userId && <>
<div style={{ marginTop: 20 }}>
<Typography.Text>分组</Typography.Text>
</div>
<Select
placeholder={'请选择分组'}
name="group"
fluid
search
selection
allowAdditions
additionLabel={'请在系统设置页面编辑分组倍率以添加新的分组:'}
onChange={value => handleInputChange('group', value)}
value={inputs.group}
autoComplete="new-password"
optionList={groupOptions}
/>
<div style={{ marginTop: 20 }}>
<Typography.Text>{`剩余额度${renderQuotaWithPrompt(quota)}`}</Typography.Text>
</div>
<Input
name="quota"
placeholder={'请输入新的剩余额度'}
onChange={value => handleInputChange('quota', value)}
value={quota}
type={'number'}
autoComplete="new-password"
/>
</>
}
<Divider style={{ marginTop: 20 }}>以下信息不可修改</Divider>
<div style={{ marginTop: 20 }}>
<Typography.Text>已绑定的 GitHub 账户</Typography.Text>
</div>
<Input
name="github_id"
value={github_id}
autoComplete="new-password"
placeholder="此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改"
readonly
/>
<div style={{ marginTop: 20 }}>
<Typography.Text>已绑定的微信账户</Typography.Text>
</div>
<Input
name="wechat_id"
value={wechat_id}
autoComplete="new-password"
placeholder="此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改"
readonly
/>
<Input
name="telegram_id"
value={telegram_id}
autoComplete="new-password"
placeholder="此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改"
readonly
/>
<div style={{ marginTop: 20 }}>
<Typography.Text>已绑定的邮箱账户</Typography.Text>
</div>
<Input
name="email"
value={email}
autoComplete="new-password"
placeholder="此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改"
readonly
/>
</Spin>
</SideSheet>
</>
);
};
export default EditUser;

View File

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