Compare commits
10 Commits
44356571a6
...
f55309e953
| Author | SHA1 | Date |
|---|---|---|
|
|
f55309e953 | |
|
|
6836012ab4 | |
|
|
c8d76437cb | |
|
|
04c4e17dcc | |
|
|
42070a5300 | |
|
|
01900f1a3a | |
|
|
006e723ac5 | |
|
|
6a210b9667 | |
|
|
001ed4c17f | |
|
|
ac1120fc33 |
|
|
@ -1348,7 +1348,7 @@ func AddChannel(c *gin.Context) {
|
|||
if addChannelRequest.Channel != nil {
|
||||
channelName = addChannelRequest.Channel.Name
|
||||
}
|
||||
service.RecordCreateOperation(c, "channel", 0, channelName, "创建渠道: "+channelName, "")
|
||||
service.RecordCreateOperation(c, "channel", 0, channelName, "创建渠道:"+channelName, "")
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
|
|
@ -1366,7 +1366,7 @@ func DeleteChannel(c *gin.Context) {
|
|||
}
|
||||
model.InitChannelCache()
|
||||
// 记录操作日志
|
||||
service.RecordDeleteOperation(c, "channel", id, "", fmt.Sprintf("删除渠道 (ID: %d)", id), "")
|
||||
service.RecordDeleteOperation(c, "channel", id, "", fmt.Sprintf("删除渠道 (ID:%d)", id), "")
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
|
|
@ -1721,7 +1721,7 @@ func UpdateChannel(c *gin.Context) {
|
|||
channel.Key = ""
|
||||
clearChannelInfo(&channel.Channel)
|
||||
// 记录操作日志
|
||||
service.RecordUpdateOperation(c, "channel", channel.Id, channel.Name, "更新渠道: "+channel.Name, "")
|
||||
service.RecordUpdateOperation(c, "channel", channel.Id, channel.Name, "更新渠道:"+channel.Name, "")
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
|
|
|
|||
|
|
@ -270,7 +270,7 @@ func CreateModelMeta(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
model.RefreshPricing()
|
||||
service.RecordCreateOperation(c, "model", m.Id, m.ModelName, "创建模型: "+m.ModelName, "")
|
||||
service.RecordCreateOperation(c, "model", m.Id, m.ModelName, "创建模型:"+m.ModelName, "")
|
||||
common.ApiSuccess(c, &m)
|
||||
}
|
||||
|
||||
|
|
@ -319,7 +319,7 @@ func UpdateModelMeta(c *gin.Context) {
|
|||
}
|
||||
}
|
||||
model.RefreshPricing()
|
||||
service.RecordUpdateOperation(c, "model", m.Id, m.ModelName, "更新模型: "+m.ModelName, "")
|
||||
service.RecordUpdateOperation(c, "model", m.Id, m.ModelName, "更新模型:"+m.ModelName, "")
|
||||
common.ApiSuccess(c, &m)
|
||||
}
|
||||
|
||||
|
|
@ -336,7 +336,7 @@ func DeleteModelMeta(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
model.RefreshPricing()
|
||||
service.RecordDeleteOperation(c, "model", id, "", fmt.Sprintf("删除模型 (ID: %d)", id), "")
|
||||
service.RecordDeleteOperation(c, "model", id, "", fmt.Sprintf("删除模型 (ID:%d)", id), "")
|
||||
common.ApiSuccess(c, nil)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -690,10 +690,10 @@ func UpdateOption(c *gin.Context) {
|
|||
if modelPricingOptionKeys[option.Key] {
|
||||
changes := computeModelPricingDiff(oldVal, valStr)
|
||||
if len(changes) > 0 {
|
||||
service.RecordUpdateWithDiff(c, "setting", 0, option.Key, "更新系统设置: "+option.Key, changes, "")
|
||||
service.RecordUpdateWithDiff(c, "setting", 0, option.Key, "更新系统设置:"+option.Key, changes, "")
|
||||
}
|
||||
} else {
|
||||
service.RecordUpdateOperation(c, "setting", 0, option.Key, "更新系统设置: "+option.Key, "")
|
||||
service.RecordUpdateOperation(c, "setting", 0, option.Key, "更新系统设置:"+option.Key, "")
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ func AddRedemption(c *gin.Context) {
|
|||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
service.RecordCreateOperation(c, "redemption", 0, redemption.Name, "创建兑换码: "+redemption.Name, "")
|
||||
service.RecordCreateOperation(c, "redemption", 0, redemption.Name, "创建兑换码:"+redemption.Name, "")
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
|
|
@ -122,7 +122,7 @@ func DeleteRedemption(c *gin.Context) {
|
|||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
service.RecordDeleteOperation(c, "redemption", id, "", fmt.Sprintf("删除兑换码 (ID: %d)", id), "")
|
||||
service.RecordDeleteOperation(c, "redemption", id, "", fmt.Sprintf("删除兑换码 (ID:%d)", id), "")
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
|
|
@ -161,7 +161,7 @@ func UpdateRedemption(c *gin.Context) {
|
|||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
service.RecordUpdateOperation(c, "redemption", cleanRedemption.Id, cleanRedemption.Name, "更新兑换码: "+cleanRedemption.Name, "")
|
||||
service.RecordUpdateOperation(c, "redemption", cleanRedemption.Id, cleanRedemption.Name, "更新兑换码:"+cleanRedemption.Name, "")
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@ func AddToken(c *gin.Context) {
|
|||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
service.RecordCreateOperation(c, "token", cleanToken.Id, cleanToken.Name, "创建令牌: "+cleanToken.Name, "")
|
||||
service.RecordCreateOperation(c, "token", cleanToken.Id, cleanToken.Name, "创建令牌:"+cleanToken.Name, "")
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
|
|
@ -243,7 +243,7 @@ func DeleteToken(c *gin.Context) {
|
|||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
service.RecordDeleteOperation(c, "token", id, "", fmt.Sprintf("删除令牌 (ID: %d)", id), "")
|
||||
service.RecordDeleteOperation(c, "token", id, "", fmt.Sprintf("删除令牌 (ID:%d)", id), "")
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
|
|
@ -308,7 +308,7 @@ func UpdateToken(c *gin.Context) {
|
|||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
service.RecordUpdateOperation(c, "token", cleanToken.Id, cleanToken.Name, "更新令牌: "+cleanToken.Name, "")
|
||||
service.RecordUpdateOperation(c, "token", cleanToken.Id, cleanToken.Name, "更新令牌:"+cleanToken.Name, "")
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
|
|
|
|||
|
|
@ -1206,7 +1206,7 @@ func UpdateUser(c *gin.Context) {
|
|||
if originUser.DistributorCommissionBps != updatedUser.DistributorCommissionBps {
|
||||
changes = append(changes, service.FieldChange{Field: "分销佣金比例", OldValue: fmt.Sprintf("%d‱", originUser.DistributorCommissionBps), NewValue: fmt.Sprintf("%d‱", updatedUser.DistributorCommissionBps)})
|
||||
}
|
||||
service.RecordUpdateWithDiff(c, "user", updatedUser.Id, updatedUser.Username, "更新用户: "+updatedUser.Username, changes, "")
|
||||
service.RecordUpdateWithDiff(c, "user", updatedUser.Id, updatedUser.Username, "更新用户:"+updatedUser.Username, changes, "")
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
|
|
@ -1246,7 +1246,7 @@ func AdminClearUserBinding(c *gin.Context) {
|
|||
}
|
||||
|
||||
model.RecordLog(user.Id, model.LogTypeManage, fmt.Sprintf("admin cleared %s binding for user %s", bindingType, user.Username))
|
||||
service.RecordDeleteOperation(c, "user_binding", user.Id, user.Username, "清除用户绑定["+bindingType+"]: "+user.Username, "")
|
||||
service.RecordDeleteOperation(c, "user_binding", user.Id, user.Username, "清除用户绑定["+bindingType+"]:"+user.Username, "")
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
|
|
@ -1421,7 +1421,7 @@ func DeleteUser(c *gin.Context) {
|
|||
})
|
||||
return
|
||||
}
|
||||
service.RecordDeleteOperation(c, "user", id, originUser.Username, "删除用户: "+originUser.Username, "")
|
||||
service.RecordDeleteOperation(c, "user", id, originUser.Username, "删除用户:"+originUser.Username, "")
|
||||
}
|
||||
|
||||
func DeleteSelf(c *gin.Context) {
|
||||
|
|
@ -1501,7 +1501,7 @@ func CreateUser(c *gin.Context) {
|
|||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
service.RecordCreateOperation(c, "user", cleanUser.Id, cleanUser.Username, "创建用户: "+cleanUser.Username, "")
|
||||
service.RecordCreateOperation(c, "user", cleanUser.Id, cleanUser.Username, "创建用户:"+cleanUser.Username, "")
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
|
|
@ -1781,7 +1781,7 @@ func ManageUser(c *gin.Context) {
|
|||
}
|
||||
changes = append(changes, service.FieldChange{Field: "学员", OldValue: oldLabel, NewValue: newLabel})
|
||||
}
|
||||
service.RecordUpdateWithDiff(c, "user", user.Id, user.Username, label+": "+user.Username, changes, "")
|
||||
service.RecordUpdateWithDiff(c, "user", user.Id, user.Username, label+":"+user.Username, changes, "")
|
||||
}
|
||||
|
||||
switch req.Action {
|
||||
|
|
|
|||
|
|
@ -222,7 +222,7 @@ func commonActionLabel(action string, targetType string, targetName string) stri
|
|||
}
|
||||
|
||||
if targetName != "" {
|
||||
return actionLabel + typeLabel + ": " + targetName
|
||||
return actionLabel + typeLabel + ":" + targetName
|
||||
}
|
||||
return actionLabel + typeLabel
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
import paramiko
|
||||
import os
|
||||
import sys
|
||||
|
||||
host = "26.0.12.13"
|
||||
user = "root"
|
||||
password = "root@1234"
|
||||
local_dist = r"D:\tuling_new\web\dist"
|
||||
remote_dist = "/root/tokenFactory/web/dist"
|
||||
|
||||
print("Connecting...")
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect(host, username=user, password=password)
|
||||
|
||||
# Clean old dist files AND rebuild Go
|
||||
print("Cleaning old dist and rebuilding...")
|
||||
stdin, stdout, stderr = ssh.exec_command(
|
||||
"rm -rf /root/tokenFactory/web/dist && mkdir -p /root/tokenFactory/web/dist"
|
||||
)
|
||||
stdout.read()
|
||||
|
||||
sftp = ssh.open_sftp()
|
||||
|
||||
uploaded = 0
|
||||
for root, dirs, files in os.walk(local_dist):
|
||||
rel_path = os.path.relpath(root, local_dist)
|
||||
if rel_path == ".":
|
||||
remote_dir = remote_dist
|
||||
else:
|
||||
remote_dir = remote_dist + "/" + rel_path.replace("\\", "/")
|
||||
stdin, stdout, stderr = ssh.exec_command(f"mkdir -p {remote_dir}")
|
||||
stdout.read()
|
||||
|
||||
for f in files:
|
||||
local_file = os.path.join(root, f)
|
||||
remote_file = (remote_dir + "/" + f).replace("\\", "/")
|
||||
try:
|
||||
sftp.put(local_file, remote_file)
|
||||
uploaded += 1
|
||||
if uploaded % 30 == 0:
|
||||
print(f" Uploaded {uploaded} files...")
|
||||
except Exception as e:
|
||||
print(f" FAILED: {f}: {e}")
|
||||
|
||||
sftp.close()
|
||||
print(f"Uploaded {uploaded} files.")
|
||||
|
||||
# Rebuild Go
|
||||
print("Building Go...")
|
||||
stdin, stdout, stderr = ssh.exec_command(
|
||||
"export PATH=$PATH:/usr/local/go/bin && cd /root/tokenFactory && go build -o tf . 2>&1"
|
||||
)
|
||||
out = stdout.read().decode()
|
||||
err = stderr.read().decode()
|
||||
if out.strip():
|
||||
print("stdout:", out[-300:].strip())
|
||||
if err.strip():
|
||||
print("stderr:", err[-300:].strip())
|
||||
|
||||
# Replace binary file (not in a subdirectory — Docker mounts ./token-factory as a file)
|
||||
print("Replacing binary...")
|
||||
stdin, stdout, stderr = ssh.exec_command(
|
||||
"rm -rf /root/tokenFactory/token-factory && cp /root/tokenFactory/tf /root/tokenFactory/token-factory && ls -la /root/tokenFactory/token-factory"
|
||||
)
|
||||
print(stdout.read().decode().strip())
|
||||
|
||||
# Docker restart
|
||||
print("Restarting Docker...")
|
||||
stdin, stdout, stderr = ssh.exec_command(
|
||||
"cd /root/tokenFactory && docker compose down 2>&1 && docker compose up -d 2>&1"
|
||||
)
|
||||
print(stdout.read().decode()[-500:].strip())
|
||||
|
||||
ssh.close()
|
||||
print("Done!")
|
||||
|
|
@ -11,6 +11,7 @@
|
|||
"@visactor/react-vchart": "~1.8.8",
|
||||
"@visactor/vchart": "~1.8.8",
|
||||
"@visactor/vchart-semi-theme": "~1.8.8",
|
||||
"antd": "^5.29.3",
|
||||
"axios": "1.13.5",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.11",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,38 @@ For commercial licensing, please contact support@quantumnous.com
|
|||
*/
|
||||
|
||||
import React, { lazy, Suspense, useContext, useMemo } from 'react';
|
||||
|
||||
class ErrorBoundary extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null, info: null };
|
||||
}
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
componentDidCatch(error, info) {
|
||||
this.setState({ info });
|
||||
console.error('ErrorBoundary caught:', error, info);
|
||||
}
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div style={{ padding: 40, color: 'red', fontFamily: 'monospace', whiteSpace: 'pre-wrap' }}>
|
||||
<h2>页面崩溃: {this.state.error?.message}</h2>
|
||||
<details>
|
||||
<summary>Stack</summary>
|
||||
<pre>{this.state.error?.stack}</pre>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Component Stack</summary>
|
||||
<pre>{this.state.info?.componentStack}</pre>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
import { Route, Routes, useLocation, useParams } from 'react-router-dom';
|
||||
import Loading from './components/common/ui/Loading';
|
||||
import User from './pages/User';
|
||||
|
|
@ -63,6 +95,11 @@ import LuckyBag from './pages/LuckyBag';
|
|||
import Drawing from './pages/Drawing';
|
||||
import ProfitDashboard from './pages/ProfitDashboard';
|
||||
import SettingsAntiAbuse from './pages/Setting/Operation/SettingsAntiAbuse';
|
||||
import Contact from './pages/Contact';
|
||||
import About from './pages/About';
|
||||
import UserAgreement from './pages/UserAgreement';
|
||||
import PrivacyPolicy from './pages/PrivacyPolicy';
|
||||
import Docs from './pages/Docs';
|
||||
|
||||
const Home = lazy(() => import('./pages/Home'));
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||
|
|
@ -70,10 +107,6 @@ const DistributorApply = lazy(() => import('./pages/DistributorApply'));
|
|||
const DistributorCenter = lazy(() => import('./pages/DistributorCenter'));
|
||||
const DistributorAdmin = lazy(() => import('./pages/DistributorAdmin'));
|
||||
const InviteRedirect = lazy(() => import('./pages/InviteRedirect'));
|
||||
const About = lazy(() => import('./pages/About'));
|
||||
const UserAgreement = lazy(() => import('./pages/UserAgreement'));
|
||||
const PrivacyPolicy = lazy(() => import('./pages/PrivacyPolicy'));
|
||||
const Docs = lazy(() => import('./pages/Docs'));
|
||||
|
||||
function DynamicOAuth2Callback() {
|
||||
const { provider } = useParams();
|
||||
|
|
@ -352,9 +385,11 @@ function App() {
|
|||
path='/console/personal'
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||
<PersonalSetting />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
|
|
@ -511,6 +546,14 @@ function App() {
|
|||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/contact'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||
<Contact />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/docs'
|
||||
element={
|
||||
|
|
|
|||
|
|
@ -15,16 +15,16 @@ import React, {
|
|||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Modal, Form, Typography, Space, Button } from '@douyinfe/semi-ui';
|
||||
import { IconLock } from '@douyinfe/semi-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { UserContext } from '../../context/User';
|
||||
import { Lock as IconLock } from 'lucide-react';
|
||||
import { Modal, Form, Typography, Space, Button } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
mergeSelfResponseIntoLocalUser,
|
||||
} from '../../helpers';
|
||||
import { UserContext } from '../../context/User';
|
||||
import { buildAdminUserPhoneFieldRules } from '../table/users/modals/userPhoneFormRules';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
|
|
|||
|
|
@ -44,18 +44,7 @@ import {
|
|||
redirectApplyIntentToAdminIfNeeded,
|
||||
} from '../../helpers';
|
||||
import Turnstile from 'react-turnstile';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Form,
|
||||
Icon,
|
||||
Input,
|
||||
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 { Modal } from '@douyinfe/semi-ui';
|
||||
import TelegramLoginButton from 'react-telegram-login';
|
||||
|
||||
import {
|
||||
|
|
@ -71,6 +60,17 @@ import TwoFAVerification from './TwoFAVerification';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import { SiDiscord } from 'react-icons/si';
|
||||
|
||||
/* 与首页紫色版统一的色板 */
|
||||
const ACC = '#635BFF';
|
||||
const ACC_SOFT = '#EEEBFF';
|
||||
const TINT = '#F6F4FF';
|
||||
const INK = '#0A2540';
|
||||
const SUB = '#425466';
|
||||
const DIM = '#697386';
|
||||
const LINE = '#E3E8EE';
|
||||
const SOFT_BG = '#F6F9FC';
|
||||
const BG_GRADIENT = `linear-gradient(180deg, ${TINT} 0%, #FFFFFF 70%, #FFFFFF 100%)`;
|
||||
|
||||
const LoginForm = () => {
|
||||
let navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -576,123 +576,112 @@ const LoginForm = () => {
|
|||
};
|
||||
|
||||
const renderOAuthOptions = () => {
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<div className='w-full max-w-md'>
|
||||
<div className='flex items-center justify-center mb-6 gap-2'>
|
||||
<img src={logo} alt='Logo' className='h-10 rounded-full' />
|
||||
<Title heading={3} className='!text-gray-800'>
|
||||
{systemName}
|
||||
</Title>
|
||||
</div>
|
||||
const OauthButton = ({ icon, children, onClick, loading, disabled, brandColor }) => (
|
||||
<button
|
||||
type='button'
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
className='w-full h-11 inline-flex items-center justify-center gap-2 rounded-full border bg-white text-[13.5px] font-medium transition-colors hover:bg-[#0A254006] disabled:opacity-50'
|
||||
style={{ borderColor: LINE, color: INK }}
|
||||
>
|
||||
<span
|
||||
className='inline-flex items-center justify-center w-5 h-5 shrink-0'
|
||||
style={{ color: brandColor || INK }}
|
||||
>
|
||||
{loading ? (
|
||||
<span className='block w-4 h-4 rounded-full border-2 border-t-transparent animate-spin'
|
||||
style={{ borderColor: `${ACC} transparent transparent transparent` }}
|
||||
/>
|
||||
) : (
|
||||
icon
|
||||
)}
|
||||
</span>
|
||||
<span>{children}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
<Card className='border-0 !rounded-2xl overflow-hidden'>
|
||||
<div className='flex justify-center pt-6 pb-2'>
|
||||
<Title heading={3} className='text-gray-800 dark:text-gray-200'>
|
||||
{t('登 录')}
|
||||
</Title>
|
||||
</div>
|
||||
<div className='px-2 py-8'>
|
||||
<div className='space-y-3'>
|
||||
return (
|
||||
<div className='w-full'>
|
||||
<div
|
||||
className='rounded-2xl border bg-white p-6 sm:p-7'
|
||||
style={{ borderColor: LINE, boxShadow: '0 1px 2px rgba(15,23,42,0.04)' }}
|
||||
>
|
||||
<h2
|
||||
className='text-center text-[18px] font-semibold mb-1'
|
||||
style={{ color: INK }}
|
||||
>
|
||||
{t('欢迎回来')}
|
||||
</h2>
|
||||
<p className='text-center text-[12.5px] mb-6' style={{ color: DIM }}>
|
||||
{t('选择登录方式继续')}
|
||||
</p>
|
||||
|
||||
<div className='space-y-2.5'>
|
||||
{status.wechat_login && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
icon={
|
||||
<Icon svg={<WeChatIcon />} style={{ color: '#07C160' }} />
|
||||
}
|
||||
<OauthButton
|
||||
icon={<WeChatIcon style={{ color: '#07C160' }} />}
|
||||
onClick={onWeChatLoginClicked}
|
||||
loading={wechatLoading}
|
||||
>
|
||||
<span className='ml-3'>{t('使用 微信 继续')}</span>
|
||||
</Button>
|
||||
{t('使用 微信 继续')}
|
||||
</OauthButton>
|
||||
)}
|
||||
|
||||
{status.github_oauth && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
icon={<IconGithubLogo size='large' />}
|
||||
<OauthButton
|
||||
icon={<IconGithubLogo size='large' style={{ color: '#181717' }} />}
|
||||
onClick={handleGitHubClick}
|
||||
loading={githubLoading}
|
||||
disabled={githubButtonDisabled}
|
||||
>
|
||||
<span className='ml-3'>{githubButtonText}</span>
|
||||
</Button>
|
||||
{githubButtonText}
|
||||
</OauthButton>
|
||||
)}
|
||||
|
||||
{status.discord_oauth && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
<OauthButton
|
||||
icon={
|
||||
<SiDiscord
|
||||
style={{
|
||||
color: '#5865F2',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
}}
|
||||
/>
|
||||
<SiDiscord style={{ color: '#5865F2', width: 18, height: 18 }} />
|
||||
}
|
||||
onClick={handleDiscordClick}
|
||||
loading={discordLoading}
|
||||
>
|
||||
<span className='ml-3'>{t('使用 Discord 继续')}</span>
|
||||
</Button>
|
||||
{t('使用 Discord 继续')}
|
||||
</OauthButton>
|
||||
)}
|
||||
|
||||
{status.oidc_enabled && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
<OauthButton
|
||||
icon={<OIDCIcon style={{ color: '#1877F2' }} />}
|
||||
onClick={handleOIDCClick}
|
||||
loading={oidcLoading}
|
||||
>
|
||||
<span className='ml-3'>{t('使用 OIDC 继续')}</span>
|
||||
</Button>
|
||||
{t('使用 OIDC 继续')}
|
||||
</OauthButton>
|
||||
)}
|
||||
|
||||
{status.linuxdo_oauth && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
<OauthButton
|
||||
icon={
|
||||
<LinuxDoIcon
|
||||
style={{
|
||||
color: '#E95420',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
}}
|
||||
/>
|
||||
<LinuxDoIcon style={{ color: '#E95420', width: 18, height: 18 }} />
|
||||
}
|
||||
onClick={handleLinuxDOClick}
|
||||
loading={linuxdoLoading}
|
||||
>
|
||||
<span className='ml-3'>{t('使用 LinuxDO 继续')}</span>
|
||||
</Button>
|
||||
{t('使用 LinuxDO 继续')}
|
||||
</OauthButton>
|
||||
)}
|
||||
|
||||
{status.custom_oauth_providers &&
|
||||
status.custom_oauth_providers.map((provider) => (
|
||||
<Button
|
||||
<OauthButton
|
||||
key={provider.slug}
|
||||
theme='outline'
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
icon={getOAuthProviderIcon(provider.icon || '', 20)}
|
||||
icon={getOAuthProviderIcon(provider.icon || '', 18)}
|
||||
onClick={() => handleCustomOAuthClick(provider)}
|
||||
loading={customOAuthLoading[provider.slug]}
|
||||
>
|
||||
<span className='ml-3'>
|
||||
{t('使用 {{name}} 继续', { name: provider.name })}
|
||||
</span>
|
||||
</Button>
|
||||
</OauthButton>
|
||||
))}
|
||||
|
||||
{status.telegram_oauth && (
|
||||
|
|
@ -705,188 +694,233 @@ const LoginForm = () => {
|
|||
)}
|
||||
|
||||
{status.passkey_login && passkeySupported && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
icon={<IconKey size='large' />}
|
||||
<OauthButton
|
||||
icon={<IconKey size='large' style={{ color: INK }} />}
|
||||
onClick={handlePasskeyLogin}
|
||||
loading={passkeyLoading}
|
||||
>
|
||||
<span className='ml-3'>{t('使用 Passkey 登录')}</span>
|
||||
</Button>
|
||||
{t('使用 Passkey 登录')}
|
||||
</OauthButton>
|
||||
)}
|
||||
|
||||
<Divider margin='12px' align='center'>
|
||||
{t('或')}
|
||||
</Divider>
|
||||
|
||||
<Button
|
||||
theme='solid'
|
||||
type='primary'
|
||||
className='w-full h-12 flex items-center justify-center bg-black text-white !rounded-full hover:bg-gray-800 transition-colors'
|
||||
icon={<IconMail size='large' />}
|
||||
onClick={handleEmailLoginClick}
|
||||
loading={emailLoginLoading}
|
||||
>
|
||||
<span className='ml-3'>{t('使用 邮箱或用户名 登录')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-3 my-5'>
|
||||
<div className='flex-1 h-px' style={{ background: LINE }} />
|
||||
<span
|
||||
className='text-[11px] tracking-[0.2em] font-mono'
|
||||
style={{ color: DIM }}
|
||||
>
|
||||
{t('或')}
|
||||
</span>
|
||||
<div className='flex-1 h-px' style={{ background: LINE }} />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleEmailLoginClick}
|
||||
disabled={emailLoginLoading}
|
||||
className='w-full h-11 inline-flex items-center justify-center gap-2 rounded-full text-[13.5px] font-semibold text-white transition-opacity hover:opacity-90 disabled:opacity-50'
|
||||
style={{ background: INK }}
|
||||
>
|
||||
<IconMail size='large' style={{ color: '#FFFFFF' }} />
|
||||
<span>{t('使用 邮箱或用户名 登录')}</span>
|
||||
</button>
|
||||
|
||||
{(hasUserAgreement || hasPrivacyPolicy) && (
|
||||
<div className='mt-6'>
|
||||
<Checkbox
|
||||
<label className='mt-5 flex items-start gap-2 cursor-pointer select-none'>
|
||||
<span className='relative inline-flex items-center mt-0.5'>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='peer sr-only'
|
||||
checked={agreedToTerms}
|
||||
onChange={(e) => setAgreedToTerms(e.target.checked)}
|
||||
/>
|
||||
<span
|
||||
className='w-4 h-4 rounded border flex items-center justify-center transition-colors peer-checked:border-transparent'
|
||||
style={{
|
||||
borderColor: LINE,
|
||||
background: agreedToTerms ? ACC : 'transparent',
|
||||
}}
|
||||
>
|
||||
<Text size='small' className='text-gray-600'>
|
||||
{agreedToTerms && (
|
||||
<svg width='10' height='10' viewBox='0 0 10 10' fill='none'>
|
||||
<path
|
||||
d='M1.5 5.5L4 8L8.5 2.5'
|
||||
stroke='#FFFFFF'
|
||||
strokeWidth='1.6'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<span className='text-[12px] leading-[1.5]' style={{ color: SUB }}>
|
||||
{t('我已阅读并同意')}
|
||||
{hasUserAgreement && (
|
||||
<>
|
||||
<a
|
||||
href='/user-agreement'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-600 hover:text-blue-800 mx-1'
|
||||
className='mx-1 font-medium'
|
||||
style={{ color: ACC }}
|
||||
>
|
||||
{t('用户协议')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{hasUserAgreement && hasPrivacyPolicy && t('和')}
|
||||
{hasPrivacyPolicy && (
|
||||
<>
|
||||
<a
|
||||
href='/privacy-policy'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-600 hover:text-blue-800 mx-1'
|
||||
className='mx-1 font-medium'
|
||||
style={{ color: ACC }}
|
||||
>
|
||||
{t('隐私政策')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</Checkbox>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!status.self_use_mode_enabled && (
|
||||
<div className='mt-6 text-center text-sm'>
|
||||
<Text>
|
||||
<div className='mt-5 text-center text-[13px]' style={{ color: SUB }}>
|
||||
{t('没有账户?')}{' '}
|
||||
<Link
|
||||
to='/register'
|
||||
className='text-blue-600 hover:text-blue-800 font-medium'
|
||||
className='font-medium'
|
||||
style={{ color: ACC }}
|
||||
>
|
||||
{t('注册')}
|
||||
</Link>
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEmailLoginForm = () => {
|
||||
const inputBaseClass =
|
||||
'w-full h-11 px-3 rounded-lg border bg-white text-[13.5px] outline-none transition-colors focus:border-[#635BFF] focus:ring-2 focus:ring-[#635BFF]/15';
|
||||
const inputStyle = {
|
||||
borderColor: LINE,
|
||||
color: INK,
|
||||
};
|
||||
const labelClass = 'text-[12.5px] font-medium mb-1.5 block';
|
||||
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<div className='w-full max-w-md'>
|
||||
<div className='flex items-center justify-center mb-6 gap-2'>
|
||||
<img src={logo} alt='Logo' className='h-10 rounded-full' />
|
||||
<Title heading={3}>{systemName}</Title>
|
||||
</div>
|
||||
|
||||
<Card className='border-0 !rounded-2xl overflow-hidden'>
|
||||
<div className='flex justify-center pt-6 pb-2'>
|
||||
<Title heading={3} className='text-gray-800 dark:text-gray-200'>
|
||||
{t('登 录')}
|
||||
</Title>
|
||||
</div>
|
||||
<div className='px-2 py-8'>
|
||||
{status.passkey_login && passkeySupported && (
|
||||
<Button
|
||||
theme='outline'
|
||||
type='tertiary'
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors mb-4'
|
||||
icon={<IconKey size='large' />}
|
||||
onClick={handlePasskeyLogin}
|
||||
loading={passkeyLoading}
|
||||
<div className='w-full'>
|
||||
<div
|
||||
className='rounded-2xl border bg-white p-6 sm:p-7'
|
||||
style={{ borderColor: LINE, boxShadow: '0 1px 2px rgba(15,23,42,0.04)' }}
|
||||
>
|
||||
<span className='ml-3'>{t('使用 Passkey 登录')}</span>
|
||||
</Button>
|
||||
)}
|
||||
<Form className='space-y-3'>
|
||||
<Form.Input
|
||||
field='username'
|
||||
label={t('用户名或邮箱')}
|
||||
placeholder={t('请输入您的用户名或邮箱地址')}
|
||||
name='username'
|
||||
onChange={(value) => handleChange('username', value)}
|
||||
prefix={<IconMail />}
|
||||
/>
|
||||
<h2
|
||||
className='text-center text-[18px] font-semibold mb-1'
|
||||
style={{ color: INK }}
|
||||
>
|
||||
{t('登 录')}
|
||||
</h2>
|
||||
<p className='text-center text-[12.5px] mb-6' style={{ color: DIM }}>
|
||||
{t('使用 邮箱或用户名 继续')}
|
||||
</p>
|
||||
|
||||
<Form.Input
|
||||
field='password'
|
||||
label={t('密码')}
|
||||
placeholder={t('请输入您的密码')}
|
||||
name='password'
|
||||
mode='password'
|
||||
onChange={(value) => handleChange('password', value)}
|
||||
prefix={<IconLock />}
|
||||
{status.passkey_login && passkeySupported && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={handlePasskeyLogin}
|
||||
disabled={passkeyLoading}
|
||||
className='w-full h-11 inline-flex items-center justify-center gap-2 rounded-full border bg-white text-[13.5px] font-medium transition-colors hover:bg-[#0A254006] disabled:opacity-50 mb-4'
|
||||
style={{ borderColor: LINE, color: INK }}
|
||||
>
|
||||
<span className='inline-flex items-center justify-center w-5 h-5 shrink-0'>
|
||||
{passkeyLoading ? (
|
||||
<span
|
||||
className='block w-4 h-4 rounded-full border-2 border-t-transparent animate-spin'
|
||||
style={{ borderColor: `${ACC} transparent transparent transparent` }}
|
||||
/>
|
||||
) : (
|
||||
<IconKey size='large' style={{ color: INK }} />
|
||||
)}
|
||||
</span>
|
||||
<span>{t('使用 Passkey 登录')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className='space-y-3'>
|
||||
<div>
|
||||
<label className={labelClass} style={{ color: INK }}>
|
||||
{t('用户名或邮箱')}
|
||||
</label>
|
||||
<div className='relative'>
|
||||
<span className='absolute left-3 top-1/2 -translate-y-1/2 inline-flex items-center justify-center pointer-events-none'
|
||||
style={{ color: DIM }}>
|
||||
<IconMail />
|
||||
</span>
|
||||
<input
|
||||
type='text'
|
||||
name='username'
|
||||
placeholder={t('请输入您的用户名或邮箱地址')}
|
||||
value={username}
|
||||
onChange={(e) => handleChange('username', e.target.value)}
|
||||
className={inputBaseClass + ' pl-10'}
|
||||
style={inputStyle}
|
||||
autoComplete='username'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass} style={{ color: INK }}>
|
||||
{t('密码')}
|
||||
</label>
|
||||
<div className='relative'>
|
||||
<span className='absolute left-3 top-1/2 -translate-y-1/2 inline-flex items-center justify-center pointer-events-none'
|
||||
style={{ color: DIM }}>
|
||||
<IconLock />
|
||||
</span>
|
||||
<input
|
||||
type='password'
|
||||
name='password'
|
||||
placeholder={t('请输入您的密码')}
|
||||
value={password}
|
||||
onChange={(e) => handleChange('password', e.target.value)}
|
||||
className={inputBaseClass + ' pl-10'}
|
||||
style={inputStyle}
|
||||
autoComplete='current-password'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{captchaEnabled && (
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
color: 'var(--semi-color-text-0, #1c1f23)',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label className={labelClass} style={{ color: INK }}>
|
||||
{t('验证码')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'stretch' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Input
|
||||
</label>
|
||||
<div className='flex gap-2'>
|
||||
<input
|
||||
type='text'
|
||||
placeholder={t('请输入验证码')}
|
||||
onChange={(value) => setCaptchaCode(value)}
|
||||
value={captchaCode}
|
||||
onChange={(e) => setCaptchaCode(e.target.value)}
|
||||
className={inputBaseClass + ' flex-1'}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
height: '32px',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid var(--semi-color-border, #d9d9d9)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onClick={fetchCaptcha}
|
||||
title={t('点击刷新验证码')}
|
||||
className='h-11 inline-flex items-center justify-center px-3 rounded-lg border cursor-pointer overflow-hidden shrink-0'
|
||||
style={{ borderColor: LINE, background: '#FFFFFF' }}
|
||||
>
|
||||
{captchaImage ? (
|
||||
<img
|
||||
src={captchaImage}
|
||||
alt='验证码'
|
||||
style={{ height: '32px', width: 'auto', display: 'block' }}
|
||||
className='h-8 w-auto block'
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
padding: '0 12px',
|
||||
fontSize: '12px',
|
||||
color: '#999',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<span className='text-[12px]' style={{ color: DIM }}>
|
||||
{captchaLoading ? '...' : t('点击获取')}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -896,107 +930,140 @@ const LoginForm = () => {
|
|||
)}
|
||||
|
||||
{(hasUserAgreement || hasPrivacyPolicy) && (
|
||||
<div className='pt-4'>
|
||||
<Checkbox
|
||||
<label className='pt-1 flex items-start gap-2 cursor-pointer select-none'>
|
||||
<span className='relative inline-flex items-center mt-0.5'>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='peer sr-only'
|
||||
checked={agreedToTerms}
|
||||
onChange={(e) => setAgreedToTerms(e.target.checked)}
|
||||
/>
|
||||
<span
|
||||
className='w-4 h-4 rounded border flex items-center justify-center transition-colors peer-checked:border-transparent'
|
||||
style={{
|
||||
borderColor: LINE,
|
||||
background: agreedToTerms ? ACC : 'transparent',
|
||||
}}
|
||||
>
|
||||
<Text size='small' className='text-gray-600'>
|
||||
{agreedToTerms && (
|
||||
<svg width='10' height='10' viewBox='0 0 10 10' fill='none'>
|
||||
<path
|
||||
d='M1.5 5.5L4 8L8.5 2.5'
|
||||
stroke='#FFFFFF'
|
||||
strokeWidth='1.6'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<span className='text-[12px] leading-[1.5]' style={{ color: SUB }}>
|
||||
{t('我已阅读并同意')}
|
||||
{hasUserAgreement && (
|
||||
<>
|
||||
<a
|
||||
href='/user-agreement'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-600 hover:text-blue-800 mx-1'
|
||||
className='mx-1 font-medium'
|
||||
style={{ color: ACC }}
|
||||
>
|
||||
{t('用户协议')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{hasUserAgreement && hasPrivacyPolicy && t('和')}
|
||||
{hasPrivacyPolicy && (
|
||||
<>
|
||||
<a
|
||||
href='/privacy-policy'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-600 hover:text-blue-800 mx-1'
|
||||
className='mx-1 font-medium'
|
||||
style={{ color: ACC }}
|
||||
>
|
||||
{t('隐私政策')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</Checkbox>
|
||||
</div>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className='space-y-2 pt-2'>
|
||||
<Button
|
||||
theme='solid'
|
||||
className='w-full !rounded-full'
|
||||
type='primary'
|
||||
htmlType='submit'
|
||||
<button
|
||||
type='submit'
|
||||
onClick={handleSubmit}
|
||||
loading={loginLoading}
|
||||
disabled={
|
||||
(hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms
|
||||
loginLoading ||
|
||||
((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms)
|
||||
}
|
||||
className='w-full h-11 inline-flex items-center justify-center gap-2 rounded-full text-[13.5px] font-semibold text-white transition-opacity hover:opacity-90 disabled:opacity-50'
|
||||
style={{ background: INK }}
|
||||
>
|
||||
{t('继续')}
|
||||
</Button>
|
||||
{loginLoading ? (
|
||||
<span
|
||||
className='block w-4 h-4 rounded-full border-2 border-t-transparent animate-spin'
|
||||
style={{ borderColor: '#FFFFFF transparent transparent transparent' }}
|
||||
/>
|
||||
) : null}
|
||||
<span>{t('继续')}</span>
|
||||
</button>
|
||||
|
||||
<Button
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
className='w-full !rounded-full'
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleResetPasswordClick}
|
||||
loading={resetPasswordLoading}
|
||||
disabled={resetPasswordLoading}
|
||||
className='w-full h-11 inline-flex items-center justify-center rounded-full text-[13.5px] font-medium transition-colors hover:bg-[#0A254006] disabled:opacity-50'
|
||||
style={{ color: SUB }}
|
||||
>
|
||||
{t('忘记密码?')}
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
{hasOAuthLoginOptions && (
|
||||
<>
|
||||
<Divider margin='12px' align='center'>
|
||||
{t('或')}
|
||||
</Divider>
|
||||
|
||||
<div className='mt-4 text-center'>
|
||||
<Button
|
||||
theme='outline'
|
||||
type='tertiary'
|
||||
className='w-full !rounded-full'
|
||||
onClick={handleOtherLoginOptionsClick}
|
||||
loading={otherLoginOptionsLoading}
|
||||
<div className='flex items-center gap-3 my-5'>
|
||||
<div className='flex-1 h-px' style={{ background: LINE }} />
|
||||
<span
|
||||
className='text-[11px] tracking-[0.2em] font-mono'
|
||||
style={{ color: DIM }}
|
||||
>
|
||||
{t('其他登录选项')}
|
||||
</Button>
|
||||
{t('或')}
|
||||
</span>
|
||||
<div className='flex-1 h-px' style={{ background: LINE }} />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleOtherLoginOptionsClick}
|
||||
disabled={otherLoginOptionsLoading}
|
||||
className='w-full h-11 inline-flex items-center justify-center gap-2 rounded-full border bg-white text-[13.5px] font-medium transition-colors hover:bg-[#0A254006] disabled:opacity-50'
|
||||
style={{ borderColor: LINE, color: INK }}
|
||||
>
|
||||
{otherLoginOptionsLoading ? (
|
||||
<span
|
||||
className='block w-4 h-4 rounded-full border-2 border-t-transparent animate-spin'
|
||||
style={{ borderColor: `${ACC} transparent transparent transparent` }}
|
||||
/>
|
||||
) : null}
|
||||
<span>{t('其他登录选项')}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!status.self_use_mode_enabled && (
|
||||
<div className='mt-6 text-center text-sm'>
|
||||
<Text>
|
||||
<div className='mt-5 text-center text-[13px]' style={{ color: SUB }}>
|
||||
{t('没有账户?')}{' '}
|
||||
<Link
|
||||
to='/register'
|
||||
className='text-blue-600 hover:text-blue-800 font-medium'
|
||||
className='font-medium'
|
||||
style={{ color: ACC }}
|
||||
>
|
||||
{t('注册')}
|
||||
</Link>
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -1025,17 +1092,24 @@ const LoginForm = () => {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<Form>
|
||||
<Form.Input
|
||||
field='wechat_verification_code'
|
||||
<div>
|
||||
<label
|
||||
className='text-[12.5px] font-medium mb-1.5 block'
|
||||
style={{ color: INK }}
|
||||
>
|
||||
{t('验证码')}
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
placeholder={t('验证码')}
|
||||
label={t('验证码')}
|
||||
value={inputs.wechat_verification_code}
|
||||
onChange={(value) =>
|
||||
handleChange('wechat_verification_code', value)
|
||||
onChange={(e) =>
|
||||
handleChange('wechat_verification_code', e.target.value)
|
||||
}
|
||||
className='w-full h-11 px-3 rounded-lg border bg-white text-[13.5px] outline-none transition-colors focus:border-[#635BFF] focus:ring-2 focus:ring-[#635BFF]/15'
|
||||
style={{ borderColor: LINE, color: INK }}
|
||||
/>
|
||||
</Form>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
|
@ -1077,40 +1151,67 @@ const LoginForm = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const brandTagline = t('统一 AI 模型网关,聚合全球顶尖大模型,一站接入即刻使用');
|
||||
const brandTagline = t('统一 AI 模型接口,几十个模型,一个 base_url。');
|
||||
|
||||
return (
|
||||
<div className='flex min-h-screen'>
|
||||
{/* 左侧品牌展示区 */}
|
||||
<div className='hidden lg:flex lg:w-1/2 relative overflow-hidden bg-zinc-900 flex-col items-center justify-center px-12'>
|
||||
<div className='flex min-h-screen bg-white'>
|
||||
{/* 左侧品牌展示区 — 浅紫渐变,呼应首页 hero */}
|
||||
<div
|
||||
className='blur-ball blur-ball-indigo'
|
||||
style={{ top: '-120px', right: '-120px', width: '400px', height: '400px' }}
|
||||
/>
|
||||
className='hidden lg:flex lg:w-1/2 relative overflow-hidden flex-col items-center justify-center px-12'
|
||||
style={{ background: BG_GRADIENT }}
|
||||
>
|
||||
<div
|
||||
className='blur-ball blur-ball-teal'
|
||||
style={{ bottom: '-80px', left: '-80px', width: '350px', height: '350px' }}
|
||||
className='pointer-events-none absolute inset-0'
|
||||
style={{
|
||||
background: `radial-gradient(60% 60% at 50% 30%, ${ACC_SOFT} 0%, transparent 70%)`,
|
||||
}}
|
||||
/>
|
||||
<div className='relative z-10 text-center max-w-md'>
|
||||
<img src={logo} alt='Logo' className='w-20 h-20 rounded-2xl mx-auto mb-8 shadow-2xl' />
|
||||
<h1 className='text-4xl font-bold text-white mb-4 tracking-tight'>{systemName}</h1>
|
||||
<p className='text-lg text-zinc-400 leading-relaxed'>{brandTagline}</p>
|
||||
<div className='mt-12 flex gap-3 justify-center'>
|
||||
<div className='w-12 h-1 rounded-full bg-emerald-400' />
|
||||
<div className='w-8 h-1 rounded-full bg-blue-400' />
|
||||
<div className='w-8 h-1 rounded-full bg-violet-400' />
|
||||
<div className='w-6 h-1 rounded-full bg-amber-400' />
|
||||
<img
|
||||
src={logo}
|
||||
alt='Logo'
|
||||
className='w-16 h-16 rounded-2xl mx-auto mb-8 object-cover'
|
||||
style={{ boxShadow: '0 18px 40px -12px rgba(99,91,255,0.35)' }}
|
||||
/>
|
||||
<div className='inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs mb-6'
|
||||
style={{ borderColor: LINE, color: DIM, background: 'rgba(255,255,255,0.7)' }}>
|
||||
<span className='h-1.5 w-1.5 rounded-full' style={{ background: ACC }} />
|
||||
<span>{t('All systems normal')}</span>
|
||||
</div>
|
||||
<h1
|
||||
className='font-heading text-[40px] font-semibold leading-[1.1] tracking-[-0.02em] mb-5'
|
||||
style={{ color: INK }}
|
||||
>
|
||||
{systemName}
|
||||
</h1>
|
||||
<p
|
||||
className='text-[15px] leading-relaxed max-w-[360px] mx-auto'
|
||||
style={{ color: SUB }}
|
||||
>
|
||||
{brandTagline}
|
||||
</p>
|
||||
<div
|
||||
className='mt-10 mx-auto inline-flex items-center gap-2 rounded-full px-3 py-1.5 text-[11px] font-mono'
|
||||
style={{ background: 'rgba(255,255,255,0.7)', color: DIM, border: `1px solid ${LINE}` }}
|
||||
>
|
||||
<span className='h-1.5 w-1.5 rounded-full bg-emerald-400' />
|
||||
POST /v1/chat/completions · 312ms
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧登录表单区 */}
|
||||
<div className='flex-1 flex items-center justify-center bg-white dark:bg-zinc-950 px-4 sm:px-6 lg:px-8 py-12'>
|
||||
<div className='flex-1 flex items-center justify-center px-4 sm:px-6 lg:px-8 py-12 bg-white'>
|
||||
<div className='w-full max-w-sm'>
|
||||
{/* 移动端显示 logo */}
|
||||
<div className='lg:hidden flex items-center justify-center gap-2 mb-8'>
|
||||
<img src={logo} alt='Logo' className='w-8 h-8 rounded-full' />
|
||||
<span className='text-lg font-semibold'>{systemName}</span>
|
||||
<span
|
||||
className='text-lg font-semibold'
|
||||
style={{ color: INK }}
|
||||
>
|
||||
{systemName}
|
||||
</span>
|
||||
</div>
|
||||
{showEmailLogin || !hasOAuthLoginOptions
|
||||
? renderEmailLoginForm()
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ For commercial licensing, please contact support@quantumnous.com
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Inbox } from 'lucide-react';
|
||||
|
||||
const ACC = '#635BFF';
|
||||
const ACC_SOFT = '#EEEBFF';
|
||||
const INK = '#0A2540';
|
||||
const SUB = '#425466';
|
||||
const DIM = '#697386';
|
||||
|
||||
const toText = (v) =>
|
||||
typeof v === 'string'
|
||||
? v
|
||||
: v?.props?.children || '';
|
||||
|
||||
const EmptyState = ({ icon, title, description, children, style }) => {
|
||||
const Icon = icon || Inbox;
|
||||
const titleText = toText(title);
|
||||
const descText = toText(description);
|
||||
return (
|
||||
<div
|
||||
className='flex flex-col items-center justify-center py-12 text-center'
|
||||
style={style}
|
||||
>
|
||||
<div
|
||||
className='inline-flex h-12 w-12 items-center justify-center rounded-2xl'
|
||||
style={{ background: ACC_SOFT, color: ACC }}
|
||||
>
|
||||
<Icon size={22} />
|
||||
</div>
|
||||
{titleText && (
|
||||
<p className='mt-4 text-sm font-semibold' style={{ color: INK }}>
|
||||
{titleText}
|
||||
</p>
|
||||
)}
|
||||
{descText && (
|
||||
<p className='mt-1 text-sm' style={{ color: DIM }}>
|
||||
{descText}
|
||||
</p>
|
||||
)}
|
||||
{!titleText && !descText && (
|
||||
<p className='mt-4 text-sm' style={{ color: DIM }}>
|
||||
暂无数据
|
||||
</p>
|
||||
)}
|
||||
{children && <div className='mt-4'>{children}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmptyState;
|
||||
|
|
@ -50,8 +50,7 @@ const Dashboard = () => {
|
|||
const { unreadCount } = useUserMessageUnreadCount(user);
|
||||
|
||||
return (
|
||||
<div className='min-h-full bg-slate-50 pb-16 text-slate-900 dark:bg-neutral-950 dark:text-white'>
|
||||
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
|
||||
<div className='flex flex-col gap-6 pb-8'>
|
||||
<TopBar
|
||||
onSearch={() => {}}
|
||||
onRefresh={dashboard.refresh}
|
||||
|
|
@ -60,7 +59,7 @@ const Dashboard = () => {
|
|||
unreadCount={unreadCount}
|
||||
/>
|
||||
|
||||
<div className='space-y-6 pt-8 pb-8'>
|
||||
<div className='space-y-6 pb-8'>
|
||||
<Hero user={user} />
|
||||
|
||||
{/* 第一行:5 个核心指标卡 */}
|
||||
|
|
@ -98,7 +97,6 @@ const Dashboard = () => {
|
|||
<span>{getSystemName()} · 2026</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { IconClose } from '@douyinfe/semi-icons';
|
|||
import SiderBar from './SiderBar';
|
||||
import App from '../../App';
|
||||
import FooterBar from './Footer';
|
||||
import OnboardingWizard from '../onboarding/OnboardingWizard';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { useIsMobile } from '../../hooks/common/useIsMobile';
|
||||
|
|
@ -402,6 +403,7 @@ const PageLayout = () => {
|
|||
)}
|
||||
</Layout>
|
||||
</Layout>
|
||||
<OnboardingWizard />
|
||||
<ToastContainer />
|
||||
</Layout>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ const routerMap = {
|
|||
'model-heat': '/console/model-heat',
|
||||
playground: '/console/playground',
|
||||
benchmarks: '/benchmarks',
|
||||
'api-tester': '/console/api-tester',
|
||||
personal: '/console/personal',
|
||||
supplier: null,
|
||||
distributor: '/console/distributor/admin',
|
||||
|
|
@ -90,6 +91,11 @@ const SiderBar = ({ onNavigate = () => {} }) => {
|
|||
itemKey: 'benchmarks',
|
||||
to: '/benchmarks',
|
||||
},
|
||||
{
|
||||
text: t('API 测速'),
|
||||
itemKey: 'api-tester',
|
||||
to: '/api-tester',
|
||||
},
|
||||
{
|
||||
text: t('数据看板'),
|
||||
itemKey: 'detail',
|
||||
|
|
@ -201,6 +207,12 @@ const SiderBar = ({ onNavigate = () => {} }) => {
|
|||
|
||||
const adminItems = useMemo(() => {
|
||||
const items = [
|
||||
{
|
||||
text: t('操作记录'),
|
||||
itemKey: 'operation-log',
|
||||
to: '/console/operation-log',
|
||||
className: isAdmin() ? '' : 'tableHiddle',
|
||||
},
|
||||
{
|
||||
text: t('渠道管理'),
|
||||
itemKey: 'channel',
|
||||
|
|
@ -243,12 +255,6 @@ const SiderBar = ({ onNavigate = () => {} }) => {
|
|||
to: '/user',
|
||||
className: isAdmin() ? '' : 'tableHiddle',
|
||||
},
|
||||
{
|
||||
text: t('操作记录'),
|
||||
itemKey: 'operation-log',
|
||||
to: '/console/operation-log',
|
||||
className: isAdmin() ? '' : 'tableHiddle',
|
||||
},
|
||||
{
|
||||
text: t('代理管理'),
|
||||
itemKey: 'distributor',
|
||||
|
|
|
|||
|
|
@ -17,86 +17,88 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Input, Dropdown, Typography } from '@douyinfe/semi-ui';
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Input, Typography } from '@douyinfe/semi-ui';
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
import { API, isAdmin, showSuccess } from '../../../helpers';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const mockSearchData = [
|
||||
{
|
||||
month: '四月 2026',
|
||||
items: [
|
||||
{ id: 1, name: 'Google: Gemma 4 31B', icon: '⬥', color: 'text-blue-500' },
|
||||
{
|
||||
id: 2,
|
||||
name: 'Qwen: Qwen3.6 Plus (free)',
|
||||
icon: '⬡',
|
||||
color: 'text-purple-500',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Z.ai: GLM 5V Turbo',
|
||||
icon: '⬢',
|
||||
color: 'text-gray-800 dark:text-white',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Arcee AI: Trinity Large Thinking',
|
||||
icon: '⬢',
|
||||
color: 'text-teal-500',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'xAI: Grok 4.20 Multi-Agent',
|
||||
icon: '⚡',
|
||||
color: 'text-gray-800 dark:text-white',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'xAI: Grok 4.20',
|
||||
icon: '⚡',
|
||||
color: 'text-gray-800 dark:text-white',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
month: '三月 2026',
|
||||
items: [
|
||||
{
|
||||
id: 7,
|
||||
name: 'Google: Lyria 3 Pro Preview',
|
||||
icon: '⬥',
|
||||
color: 'text-blue-500',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const { Text } = Typography;
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
function debounce(fn, ms) {
|
||||
let timer;
|
||||
return (...args) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn(...args), ms);
|
||||
};
|
||||
}
|
||||
|
||||
const SearchDropdown = ({ isMobile }) => {
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [filteredData, setFilteredData] = useState(mockSearchData);
|
||||
const [models, setModels] = useState([]);
|
||||
const [results, setResults] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const dropdownRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 预加载模型数据
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await API.get('/api/pricing', { disableDuplicate: true });
|
||||
if (res?.data?.success && Array.isArray(res.data.data)) {
|
||||
setModels(res.data.data);
|
||||
}
|
||||
} catch {}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const doSearch = useCallback(
|
||||
(q) => {
|
||||
const trimmed = q.trim();
|
||||
if (!trimmed) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const lower = trimmed.toLowerCase();
|
||||
const matched = [];
|
||||
for (const m of models) {
|
||||
const name = (m.model_name || '').toLowerCase();
|
||||
const desc = (m.description || '').toLowerCase();
|
||||
const tags = (m.tags || '').toLowerCase();
|
||||
if (name.includes(lower) || desc.includes(lower) || tags.includes(lower)) {
|
||||
matched.push({
|
||||
type: 'model',
|
||||
label: m.model_name,
|
||||
desc: m.description || '',
|
||||
navigate: `/pricing?search=${encodeURIComponent(trimmed)}`,
|
||||
});
|
||||
}
|
||||
if (matched.length >= 8) break;
|
||||
}
|
||||
setResults(matched);
|
||||
setLoading(false);
|
||||
},
|
||||
[models],
|
||||
);
|
||||
|
||||
const debouncedSearch = useCallback(
|
||||
debounce(doSearch, SEARCH_DEBOUNCE_MS),
|
||||
[doSearch],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchValue.trim() === '') {
|
||||
setFilteredData(mockSearchData);
|
||||
} else {
|
||||
const filtered = mockSearchData
|
||||
.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) =>
|
||||
item.name.toLowerCase().includes(searchValue.toLowerCase()),
|
||||
),
|
||||
}))
|
||||
.filter((group) => group.items.length > 0);
|
||||
setFilteredData(filtered);
|
||||
}
|
||||
}, [searchValue]);
|
||||
debouncedSearch(searchValue);
|
||||
}, [searchValue, debouncedSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === '/') {
|
||||
if (event.key === '/' && document.activeElement === document.body) {
|
||||
event.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
setVisible(true);
|
||||
|
|
@ -106,89 +108,110 @@ const SearchDropdown = ({ isMobile }) => {
|
|||
inputRef.current?.blur();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
const handleItemClick = (item) => {
|
||||
console.log('Selected:', item);
|
||||
setVisible(false);
|
||||
setSearchValue('');
|
||||
setResults([]);
|
||||
if (item.navigate) {
|
||||
if (item.navigate.startsWith('/')) {
|
||||
navigate(item.navigate);
|
||||
} else {
|
||||
window.open(item.navigate, '_blank');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const typeLabel = (type) => {
|
||||
switch (type) {
|
||||
case 'model': return '模型';
|
||||
default: return '';
|
||||
}
|
||||
};
|
||||
|
||||
const renderDropdownContent = () => {
|
||||
if (!searchValue.trim()) {
|
||||
return (
|
||||
<div className='w-80 md:w-96 max-h-96 overflow-y-auto'>
|
||||
{filteredData.length > 0 ? (
|
||||
filteredData.map((group) => (
|
||||
<div key={group.month} className='py-2'>
|
||||
<Typography.Text className='!px-4 !py-2 !text-xs !font-semibold !text-semi-color-text-2 dark:!text-gray-400 uppercase tracking-wider block'>
|
||||
{group.month}
|
||||
</Typography.Text>
|
||||
<div>
|
||||
{group.items.map((item) => (
|
||||
<div className='px-4 py-8 text-center'>
|
||||
<Text className='!text-sm !text-semi-color-text-2 dark:!text-gray-400'>
|
||||
输入关键词搜索模型
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='px-4 py-8 text-center'>
|
||||
<Text className='!text-sm !text-semi-color-text-2 dark:!text-gray-400'>
|
||||
搜索中...
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<div className='px-4 py-8 text-center'>
|
||||
<Text className='!text-sm !text-semi-color-text-2 dark:!text-gray-400'>
|
||||
未找到匹配结果
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className='max-h-96 overflow-y-auto py-2'>
|
||||
{results.map((item, i) => (
|
||||
<div
|
||||
key={item.id}
|
||||
key={`${item.type}-${i}`}
|
||||
onClick={() => handleItemClick(item)}
|
||||
className='px-4 py-2.5 flex items-center gap-3 cursor-pointer hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 transition-colors'
|
||||
className='px-4 py-2.5 flex items-center gap-3 cursor-pointer hover:bg-semi-color-fill-1 dark:hover:bg-gray-700 transition-colors'
|
||||
>
|
||||
<span className={`text-lg ${item.color}`}>{item.icon}</span>
|
||||
<Typography.Text className='!text-sm !font-medium !text-semi-color-text-0 dark:!text-gray-200'>
|
||||
{item.name}
|
||||
</Typography.Text>
|
||||
<span className='shrink-0 rounded bg-blue-100 dark:bg-blue-900/40 px-1.5 py-0.5 text-xs text-blue-600 dark:text-blue-300'>
|
||||
{typeLabel(item.type)}
|
||||
</span>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<Text className='!text-sm !font-medium !text-semi-color-text-0 dark:!text-gray-200 block truncate'>
|
||||
{item.label}
|
||||
</Text>
|
||||
{item.desc && (
|
||||
<Text className='!text-xs !text-semi-color-text-2 dark:!text-gray-400 block truncate'>
|
||||
{item.desc}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className='px-4 py-8 text-center'>
|
||||
<Typography.Text className='!text-sm !text-semi-color-text-2 dark:!text-gray-400'>
|
||||
No results found
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='relative' ref={dropdownRef}>
|
||||
<Dropdown
|
||||
visible={visible}
|
||||
onVisibleChange={setVisible}
|
||||
position='bottomLeft'
|
||||
trigger='custom'
|
||||
getPopupContainer={() => dropdownRef.current}
|
||||
render={
|
||||
<div className='!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-800 dark:!border-gray-600'>
|
||||
{renderDropdownContent()}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='relative'>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder='Search'
|
||||
prefix={
|
||||
<IconSearch className='text-semi-color-text-2 dark:text-gray-400' />
|
||||
}
|
||||
placeholder='搜索模型...'
|
||||
prefix={<IconSearch className='text-semi-color-text-2 dark:text-gray-400' />}
|
||||
suffix={
|
||||
<kbd className='px-1.5 py-0.5 text-xs font-semibold !text-semi-color-text-2 dark:!text-gray-400 !bg-semi-color-fill-0 dark:!bg-gray-700 border !border-semi-color-border dark:!border-gray-600 rounded'>
|
||||
<kbd className='hidden sm:inline-block px-1.5 py-0.5 text-xs font-semibold text-semi-color-text-2 dark:text-gray-400 bg-semi-color-fill-0 dark:bg-gray-700 border border-semi-color-border dark:border-gray-600 rounded'>
|
||||
/
|
||||
</kbd>
|
||||
}
|
||||
value={searchValue}
|
||||
onChange={setSearchValue}
|
||||
onFocus={() => setVisible(true)}
|
||||
className='!w-48 lg:!w-64 !h-9 !text-sm !bg-semi-color-fill-0 dark:!bg-gray-800/50 !border-semi-color-border dark:!border-gray-700 hover:!border-semi-color-primary dark:hover:!border-blue-400 focus:!border-semi-color-primary dark:focus:!border-blue-400'
|
||||
style={{ borderRadius: '6px', paddingRight: '10px' }}
|
||||
onFocus={() => { if (searchValue.trim()) setVisible(true); }}
|
||||
onBlur={() => setTimeout(() => setVisible(false), 150)}
|
||||
className='!w-40 lg:!w-56 !h-9 !text-sm !bg-semi-color-fill-0 dark:!bg-gray-800/50 !border-semi-color-border dark:!border-gray-700 hover:!border-semi-color-primary dark:hover:!border-blue-400 focus:!border-semi-color-primary dark:focus:!border-blue-400'
|
||||
style={{ borderRadius: '6px' }}
|
||||
/>
|
||||
</div>
|
||||
</Dropdown>
|
||||
{visible && (results.length > 0 || searchValue.trim()) && (
|
||||
<div className='absolute left-0 top-full mt-1 w-80 md:w-96 bg-semi-color-bg-overlay border border-semi-color-border shadow-lg rounded-lg dark:bg-gray-800 dark:border-gray-600 z-50'>
|
||||
{renderDropdownContent()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -43,22 +43,25 @@ const DeploymentAccessGuard = ({
|
|||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
|
||||
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
|
||||
<Card loading={true} style={{ minHeight: '400px' }}>
|
||||
<div style={{ textAlign: 'center', padding: '50px 0' }}>
|
||||
<Text type='secondary'>{t('加载设置中...')}</Text>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isEnabled) {
|
||||
return (
|
||||
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
|
||||
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
|
||||
<div
|
||||
className='mt-[60px] px-4'
|
||||
style={{
|
||||
minHeight: 'calc(100vh - 60px)',
|
||||
minHeight: 'calc(100vh - 160px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
|
|
@ -277,18 +280,22 @@ const DeploymentAccessGuard = ({
|
|||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (connectionLoading || (connectionOk === null && !connectionError)) {
|
||||
return (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
|
||||
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
|
||||
<Card loading={true} style={{ minHeight: '400px' }}>
|
||||
<div style={{ textAlign: 'center', padding: '50px 0' }}>
|
||||
<Text type='secondary'>{t('正在检查 io.net 连接...')}</Text>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -301,10 +308,11 @@ const DeploymentAccessGuard = ({
|
|||
const detail = connectionError?.message || '';
|
||||
|
||||
return (
|
||||
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
|
||||
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
|
||||
<div
|
||||
className='mt-[60px] px-4'
|
||||
style={{
|
||||
minHeight: 'calc(100vh - 60px)',
|
||||
minHeight: 'calc(100vh - 160px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
|
|
@ -403,6 +411,8 @@ const DeploymentAccessGuard = ({
|
|||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,198 @@
|
|||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import { Modal, Button, Steps, Typography, Space } from '@douyinfe/semi-ui';
|
||||
import { IconCopy, IconLink } from '@douyinfe/semi-icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { UserContext } from '../../context/User';
|
||||
import { copy, showSuccess } from '../../helpers';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const STORAGE_KEY = 'onboarding_completed';
|
||||
|
||||
const OnboardingWizard = () => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [step, setStep] = useState(0);
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [userState] = useContext(UserContext);
|
||||
|
||||
useEffect(() => {
|
||||
const completed = localStorage.getItem(STORAGE_KEY);
|
||||
if (!completed && userState?.user) {
|
||||
setVisible(true);
|
||||
}
|
||||
}, [userState?.user]);
|
||||
|
||||
const handleClose = () => {
|
||||
localStorage.setItem(STORAGE_KEY, '1');
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
const getTokenStepExample = () => {
|
||||
const baseUrl = window.location.origin;
|
||||
const demoKey = 'sk-your-api-key-here';
|
||||
return `curl ${baseUrl}/v1/chat/completions \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "Authorization: Bearer ${demoKey}" \\
|
||||
-d '{
|
||||
"model": "gpt-3.5-turbo",
|
||||
"messages": [{"role": "user", "content": "Hello!"}]
|
||||
}'`;
|
||||
};
|
||||
|
||||
const steps = [
|
||||
{
|
||||
title: t('获取 API Key'),
|
||||
content: (
|
||||
<div className='space-y-4'>
|
||||
<Text>{t('前往令牌管理页面创建您的第一个 API Key。')}</Text>
|
||||
<div className='rounded-lg bg-semi-color-fill-0 dark:bg-gray-800 p-4'>
|
||||
<Text className='!text-sm !text-semi-color-text-2'>
|
||||
1. 点击下方按钮进入令牌页面<br />
|
||||
2. 点击「添加令牌」创建新 Key<br />
|
||||
3. 复制生成的 sk- 开头的 Key 妥善保存
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
type='primary'
|
||||
icon={<IconLink />}
|
||||
onClick={() => {
|
||||
handleClose();
|
||||
navigate('/console/token');
|
||||
}}
|
||||
>
|
||||
{t('前往令牌管理')}
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('选择模型'),
|
||||
content: (
|
||||
<div className='space-y-4'>
|
||||
<Text>{t('浏览模型市场,选择适合您需求的模型。')}</Text>
|
||||
<div className='rounded-lg bg-semi-color-fill-0 dark:bg-gray-800 p-4'>
|
||||
<Text className='!text-sm !text-semi-color-text-2'>
|
||||
支持文本对话、图片生成、视频生成、语音等多种模型。<br />
|
||||
可按价格、供应商、功能标签筛选。
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
type='primary'
|
||||
icon={<IconLink />}
|
||||
onClick={() => {
|
||||
handleClose();
|
||||
navigate('/pricing');
|
||||
}}
|
||||
>
|
||||
{t('浏览模型市场')}
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('首次调用'),
|
||||
content: (
|
||||
<div className='space-y-4'>
|
||||
<Text>{t('使用以下 cURL 命令发送您的第一个 API 请求:')}</Text>
|
||||
<div className='relative rounded-lg bg-gray-900 dark:bg-gray-950 p-4 group'>
|
||||
<Button
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
icon={<IconCopy />}
|
||||
className='!absolute top-2 right-2 !text-gray-400 hover:!text-white'
|
||||
onClick={() => {
|
||||
copy(getTokenStepExample());
|
||||
showSuccess(t('已复制'));
|
||||
}}
|
||||
/>
|
||||
<pre className='text-xs text-green-400 overflow-x-auto whitespace-pre-wrap'>
|
||||
{getTokenStepExample()}
|
||||
</pre>
|
||||
</div>
|
||||
<Text className='!text-xs !text-semi-color-text-2'>
|
||||
{t('将 {key} 替换为您上一步创建的 API Key,将 model 替换为您选择的模型名称。', { key: 'sk-your-api-key-here' })}
|
||||
</Text>
|
||||
<Button
|
||||
type='tertiary'
|
||||
icon={<IconLink />}
|
||||
onClick={() => {
|
||||
handleClose();
|
||||
navigate('/docs');
|
||||
}}
|
||||
>
|
||||
{t('查看完整 API 文档')}
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-lg'>🚀</span>
|
||||
<Title heading={5} className='!mb-0'>
|
||||
{t('欢迎使用')}
|
||||
</Title>
|
||||
</div>
|
||||
}
|
||||
visible={visible}
|
||||
onCancel={handleClose}
|
||||
footer={
|
||||
<Space>
|
||||
{step > 0 && (
|
||||
<Button onClick={() => setStep((s) => s - 1)}>
|
||||
{t('上一步')}
|
||||
</Button>
|
||||
)}
|
||||
{step < steps.length - 1 ? (
|
||||
<Button
|
||||
type='primary'
|
||||
onClick={() => setStep((s) => s + 1)}
|
||||
>
|
||||
{t('下一步')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button type='primary' onClick={handleClose}>
|
||||
{t('开始使用')}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
width={560}
|
||||
maskClosable={false}
|
||||
>
|
||||
<Steps current={step} className='mb-6'>
|
||||
{steps.map((s) => (
|
||||
<Steps.Step key={s.title} title={s.title} />
|
||||
))}
|
||||
</Steps>
|
||||
<div className='min-h-[160px]'>{steps[step].content}</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardingWizard;
|
||||
|
|
@ -555,7 +555,8 @@ const PersonalSetting = () => {
|
|||
|
||||
|
||||
return (
|
||||
<div className='mx-auto mt-[60px] max-w-[1400px] px-4 pb-16 sm:px-6 lg:px-10'>
|
||||
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
|
||||
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
|
||||
{/* 顶部用户信息区域 */}
|
||||
<UserInfoHeader t={t} userState={userState} />
|
||||
|
||||
|
|
@ -677,6 +678,7 @@ const PersonalSetting = () => {
|
|||
setTurnstileToken={setTurnstileToken}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import {
|
|||
IconAlertTriangle,
|
||||
IconRefresh,
|
||||
IconCopy,
|
||||
IconShield,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { ScanFace } from 'lucide-react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
|
|
|||
|
|
@ -18,10 +18,11 @@ For commercial licensing, please contact support@quantumnous.com
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Tag, Space, Skeleton } from '@douyinfe/semi-ui';
|
||||
import { Tag, Space, Skeleton, Button } from '@douyinfe/semi-ui';
|
||||
import { renderQuota } from '../../../helpers';
|
||||
import CompactModeToggle from '../../common/ui/CompactModeToggle';
|
||||
import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime';
|
||||
import { Download } from 'lucide-react';
|
||||
|
||||
const LogsActions = ({
|
||||
stat,
|
||||
|
|
@ -29,6 +30,8 @@ const LogsActions = ({
|
|||
showStat,
|
||||
compactMode,
|
||||
setCompactMode,
|
||||
exporting,
|
||||
handleExportLogs,
|
||||
t,
|
||||
}) => {
|
||||
const showSkeleton = useMinimumLoadingTime(loadingStat);
|
||||
|
|
@ -83,11 +86,23 @@ const LogsActions = ({
|
|||
</Space>
|
||||
</Skeleton>
|
||||
|
||||
<Space>
|
||||
<Button
|
||||
icon={<Download size={14} />}
|
||||
size='small'
|
||||
theme='outline'
|
||||
type='tertiary'
|
||||
loading={exporting}
|
||||
onClick={handleExportLogs}
|
||||
>
|
||||
{t('导出 CSV')}
|
||||
</Button>
|
||||
<CompactModeToggle
|
||||
compactMode={compactMode}
|
||||
setCompactMode={setCompactMode}
|
||||
t={t}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -864,7 +864,7 @@ const TopUp = () => {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className='mx-auto w-full max-w-[1400px] px-4 pb-16 sm:px-6 lg:px-10'>
|
||||
<div className='mx-auto w-full max-w-[1400px] px-4 pb-16 pt-20 sm:px-6 lg:px-10'>
|
||||
{/* 划转模态框 */}
|
||||
<TransferModal
|
||||
t={t}
|
||||
|
|
|
|||
|
|
@ -28,15 +28,17 @@ import {
|
|||
Input,
|
||||
Tag,
|
||||
Select,
|
||||
Space,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { Coins } from 'lucide-react';
|
||||
import { Coins, Download } from 'lucide-react';
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
import { API, timestamp2string } from '../../../helpers';
|
||||
import { isAdmin } from '../../../helpers/utils';
|
||||
import { exportCSV } from '../../../helpers/export';
|
||||
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
||||
const { Text } = Typography;
|
||||
|
||||
|
|
@ -202,6 +204,45 @@ const TopupHistoryModal = ({ visible, onCancel, t }) => {
|
|||
}
|
||||
};
|
||||
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const adminNow = isAdmin();
|
||||
const base = adminNow ? '/api/user/topup' : '/api/user/topup/self';
|
||||
const tn = tradeNoFilter.trim();
|
||||
const un = usernameFilter.trim();
|
||||
const params = { p: 1, page_size: 10000 };
|
||||
if (tn) params.trade_no = tn;
|
||||
if (adminNow && un) params.username = un;
|
||||
if (statusFilter && statusFilter !== TOPUP_STATUS_ALL) {
|
||||
params.status = statusFilter;
|
||||
}
|
||||
const res = await API.get(base, { params, disableDuplicate: true });
|
||||
const { success, data } = res.data;
|
||||
if (success && data?.items?.length) {
|
||||
const columns = [
|
||||
{ title: t('订单号'), dataIndex: 'trade_no' },
|
||||
{ title: t('用户名'), dataIndex: 'username' },
|
||||
{ title: t('支付方式'), dataIndex: 'payment_method' },
|
||||
{ title: t('充值额度'), dataIndex: 'amount' },
|
||||
{ title: t('支付金额'), dataIndex: 'money' },
|
||||
{ title: t('状态'), dataIndex: 'status' },
|
||||
{ title: t('创建时间'), dataIndex: 'create_time', render: (v) => v ? timestamp2string(v) : '' },
|
||||
];
|
||||
exportCSV(data.items, columns, `topup-records-${new Date().toISOString().slice(0, 10)}`);
|
||||
Toast.success({ content: t('导出成功') });
|
||||
} else {
|
||||
Toast.info({ content: t('无数据可导出') });
|
||||
}
|
||||
} catch {
|
||||
Toast.error({ content: t('导出失败') });
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmAdminComplete = (tradeNo) => {
|
||||
Modal.confirm({
|
||||
title: t('确认补单'),
|
||||
|
|
@ -332,6 +373,18 @@ const TopupHistoryModal = ({ visible, onCancel, t }) => {
|
|||
>
|
||||
{/* 筛选条件单行排列;宽度不足时横向滚动,避免折成两行 */}
|
||||
<div className='mb-3 flex w-full flex-row flex-nowrap items-center gap-2 overflow-x-auto'>
|
||||
<div className='shrink-0'>
|
||||
<Button
|
||||
icon={<Download size={14} />}
|
||||
size='small'
|
||||
theme='outline'
|
||||
type='tertiary'
|
||||
loading={exporting}
|
||||
onClick={handleExport}
|
||||
>
|
||||
{t('导出 CSV')}
|
||||
</Button>
|
||||
</div>
|
||||
{userIsAdmin ? (
|
||||
<Input
|
||||
className='min-w-[104px] flex-1'
|
||||
|
|
|
|||
|
|
@ -19,9 +19,10 @@ For commercial licensing, please contact support@quantumnous.com
|
|||
|
||||
import React, { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { reducer, initialState } from './reducer';
|
||||
import { API } from '../../helpers/api';
|
||||
import { initialState, reducer } from './reducer';
|
||||
import { mergeSelfResponseIntoLocalUser } from '../../helpers/data';
|
||||
import { normalizeLanguage } from '../../i18n/language';
|
||||
import { API, mergeSelfResponseIntoLocalUser } from '../../helpers';
|
||||
import AdminInitialSetupModal from '../../components/auth/AdminInitialSetupModal';
|
||||
|
||||
export const UserContext = React.createContext({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* 导出数据为 CSV 文件并触发浏览器下载。
|
||||
* @param {Array<object>} rows 数据行
|
||||
* @param {Array<{title: string, dataIndex: string, render?: function}>} columns 列定义
|
||||
* @param {string} filename 文件名(不含扩展名)
|
||||
*/
|
||||
export function exportCSV(rows, columns, filename = 'export') {
|
||||
if (!rows || !rows.length) return;
|
||||
|
||||
const BOM = '';
|
||||
const header = columns.map((col) => `"${(col.title || col.dataIndex).replace(/"/g, '""')}"`).join(',');
|
||||
const body = rows
|
||||
.map((row) =>
|
||||
columns
|
||||
.map((col) => {
|
||||
let val = row[col.dataIndex];
|
||||
if (typeof col.render === 'function') {
|
||||
// render 函数返回 React 元素时提取文本
|
||||
const rendered = col.render(val, row);
|
||||
if (rendered == null || rendered === '') return '""';
|
||||
if (typeof rendered === 'string' || typeof rendered === 'number') {
|
||||
val = String(rendered).replace(/"/g, '""');
|
||||
} else if (rendered && typeof rendered === 'object' && rendered.props) {
|
||||
// 简单提取 React 元素文本
|
||||
val = extractText(rendered).replace(/"/g, '""');
|
||||
} else {
|
||||
val = String(rendered).replace(/"/g, '""');
|
||||
}
|
||||
} else {
|
||||
val = val != null ? String(val).replace(/"/g, '""') : '';
|
||||
}
|
||||
return `"${val}"`;
|
||||
})
|
||||
.join(','),
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
const csv = BOM + header + '\n' + body;
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${filename}.csv`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function extractText(element) {
|
||||
if (element == null) return '';
|
||||
if (typeof element === 'string' || typeof element === 'number') return String(element);
|
||||
if (Array.isArray(element)) return element.map(extractText).join('');
|
||||
if (element.props && element.props.children) return extractText(element.props.children);
|
||||
return '';
|
||||
}
|
||||
|
|
@ -111,13 +111,18 @@ import {
|
|||
SiOkta,
|
||||
SiOpenid,
|
||||
SiReddit,
|
||||
SiSlack,
|
||||
SiTelegram,
|
||||
SiTwitch,
|
||||
SiWechat,
|
||||
SiX,
|
||||
} from 'react-icons/si';
|
||||
|
||||
const SiSlack = (props) => (
|
||||
<svg stroke="currentColor" fill="currentColor" strokeWidth="0" viewBox="0 0 24 24" height="1em" width="1em" {...props}>
|
||||
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.528 2.528 0 0 1-2.521-2.521V2.522A2.528 2.528 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.528 2.528 0 0 1-2.521-2.522v-2.522h2.521zM15.165 17.688a2.527 2.527 0 0 1-2.521-2.523 2.527 2.527 0 0 1 2.521-2.521h6.313A2.528 2.528 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// 获取侧边栏Lucide图标组件
|
||||
export function getLucideIcon(key, selected = false) {
|
||||
const size = 16;
|
||||
|
|
|
|||
|
|
@ -18,15 +18,12 @@ For commercial licensing, please contact support@quantumnous.com
|
|||
*/
|
||||
|
||||
import { Toast, Pagination } from '@douyinfe/semi-ui';
|
||||
import { toastConstants } from '../constants';
|
||||
import React from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import {
|
||||
THINK_TAG_REGEX,
|
||||
MESSAGE_ROLES,
|
||||
} from '../constants/playground.constants';
|
||||
import { TABLE_COMPACT_MODES_KEY } from '../constants';
|
||||
import { USER_ROLES } from '../constants/user.constants';
|
||||
import { toastConstants } from '../constants/toast.constants';
|
||||
import { MOBILE_BREAKPOINT } from '../hooks/common/useIsMobile';
|
||||
|
||||
const HTMLToastContent = ({ htmlContent }) => {
|
||||
|
|
@ -144,6 +141,15 @@ export function parseCommissionPercentStringToBps(s) {
|
|||
export function getSystemName() {
|
||||
let system_name = localStorage.getItem('system_name');
|
||||
if (!system_name) return 'TuringToken';
|
||||
// 旧品牌名缓存迁移:清掉 TokenFactory / TokenFactoryOpen / 开放词元工厂 等历史默认,避免登录页/控制台标题闪烁。
|
||||
if (
|
||||
system_name === 'TokenFactory' ||
|
||||
system_name === 'TokenFactoryOpen' ||
|
||||
system_name === '开放词元工厂'
|
||||
) {
|
||||
localStorage.setItem('system_name', 'TuringToken');
|
||||
return 'TuringToken';
|
||||
}
|
||||
return system_name;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,9 +17,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal, Tag } from '@douyinfe/semi-ui';
|
||||
import { Modal, Tag, Toast } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
API,
|
||||
getTodayStartTimestamp,
|
||||
|
|
@ -38,6 +38,7 @@ import {
|
|||
} from '../../helpers';
|
||||
import { ITEMS_PER_PAGE } from '../../constants';
|
||||
import { useTableCompactMode } from '../common/useTableCompactMode';
|
||||
import { exportCSV } from '../../helpers/export';
|
||||
import ParamOverrideEntry from '../../components/table/usage-logs/components/ParamOverrideEntry';
|
||||
|
||||
export const useLogsData = () => {
|
||||
|
|
@ -550,6 +551,7 @@ export const useLogsData = () => {
|
|||
visible: false,
|
||||
record: null,
|
||||
});
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
// Initialize default column visibility
|
||||
const initDefaultColumns = () => {
|
||||
|
|
@ -1307,6 +1309,60 @@ export const useLogsData = () => {
|
|||
await loadLogs(1, pageSize);
|
||||
};
|
||||
|
||||
const handleExportLogs = useCallback(async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const {
|
||||
username,
|
||||
token_name,
|
||||
model_name,
|
||||
start_timestamp,
|
||||
end_timestamp,
|
||||
channel,
|
||||
group,
|
||||
request_id,
|
||||
logType: formLogType,
|
||||
} = getFormValues();
|
||||
const currentLogType =
|
||||
formLogType !== undefined ? formLogType : logType;
|
||||
const localStartTimestamp = Date.parse(start_timestamp) / 1000;
|
||||
const localEndTimestamp = Date.parse(end_timestamp) / 1000;
|
||||
let url;
|
||||
if (isAdminUser) {
|
||||
url = `/api/log/?p=1&page_size=10000&type=${currentLogType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}&group=${group}&request_id=${request_id}`;
|
||||
} else {
|
||||
url = `/api/log/self/?p=1&page_size=10000&type=${currentLogType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&group=${group}&request_id=${request_id}`;
|
||||
}
|
||||
url = encodeURI(url);
|
||||
const res = await API.get(url);
|
||||
const { success, data } = res.data;
|
||||
if (success && data?.items?.length) {
|
||||
const typeMap = { 0: t('未知'), 1: t('充值'), 2: t('消费'), 3: t('管理'), 4: t('系统'), 5: t('错误'), 6: t('退款') };
|
||||
const columns = [
|
||||
{ title: t('时间'), dataIndex: 'created_at', render: (v) => v ? timestamp2string(v) : '' },
|
||||
{ title: t('渠道'), dataIndex: 'channel' },
|
||||
{ title: t('用户名'), dataIndex: 'username' },
|
||||
{ title: t('令牌'), dataIndex: 'token_name' },
|
||||
{ title: t('分组'), dataIndex: 'group' },
|
||||
{ title: t('类型'), dataIndex: 'type', render: (v) => typeMap[v] || t('未知') },
|
||||
{ title: t('模型'), dataIndex: 'model_name' },
|
||||
{ title: t('输入Tokens'), dataIndex: 'prompt_tokens' },
|
||||
{ title: t('输出Tokens'), dataIndex: 'completion_tokens' },
|
||||
{ title: t('花费'), dataIndex: 'quota', render: (v) => renderQuota(v) },
|
||||
{ title: t('IP'), dataIndex: 'ip' },
|
||||
];
|
||||
exportCSV(data.items, columns, `usage-logs-${new Date().toISOString().slice(0, 10)}`);
|
||||
Toast.success({ content: t('导出成功') });
|
||||
} else {
|
||||
Toast.info({ content: t('无数据可导出') });
|
||||
}
|
||||
} catch {
|
||||
Toast.error({ content: t('导出失败') });
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}, [getFormValues, logType, isAdminUser, t]);
|
||||
|
||||
// Copy text function
|
||||
const copyText = async (e, text) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -1408,6 +1464,10 @@ export const useLogsData = () => {
|
|||
setLogType,
|
||||
openParamOverrideModal,
|
||||
|
||||
// Export
|
||||
exporting,
|
||||
handleExportLogs,
|
||||
|
||||
// Translation
|
||||
t,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -39,17 +39,17 @@ i18n
|
|||
load: 'currentOnly',
|
||||
supportedLngs: supportedLanguages,
|
||||
resources: {
|
||||
en: enTranslation,
|
||||
'zh-CN': zhCNTranslation,
|
||||
'zh-TW': zhTWTranslation,
|
||||
fr: frTranslation,
|
||||
ru: ruTranslation,
|
||||
ja: jaTranslation,
|
||||
vi: viTranslation,
|
||||
id: idTranslation,
|
||||
ms: msTranslation,
|
||||
th: thTranslation,
|
||||
sw: swTranslation,
|
||||
en: { translation: enTranslation },
|
||||
'zh-CN': { translation: zhCNTranslation },
|
||||
'zh-TW': { translation: zhTWTranslation },
|
||||
fr: { translation: frTranslation },
|
||||
ru: { translation: ruTranslation },
|
||||
ja: { translation: jaTranslation },
|
||||
vi: { translation: viTranslation },
|
||||
id: { translation: idTranslation },
|
||||
ms: { translation: msTranslation },
|
||||
th: { translation: thTranslation },
|
||||
sw: { translation: swTranslation },
|
||||
},
|
||||
fallbackLng: 'zh-CN',
|
||||
lng: 'zh-CN',
|
||||
|
|
|
|||
|
|
@ -5695,6 +5695,21 @@
|
|||
"主题切换": "Theme Toggle",
|
||||
"顶栏显示主题切换按钮,允许用户切换亮色/暗色模式": "Show theme toggle in header bar, allowing users to switch between light/dark mode",
|
||||
"语言选择器": "Language Selector",
|
||||
"顶栏显示语言选择器,允许用户切换界面语言": "Show language selector in header bar, allowing users to switch interface language"
|
||||
"顶栏显示语言选择器,允许用户切换界面语言": "Show language selector in header bar, allowing users to switch interface language",
|
||||
"All systems normal": "All systems normal",
|
||||
"函数": "function",
|
||||
"一样轻。": "— that light.",
|
||||
"把 OpenAI、Claude、Gemini、DeepSeek 等几十个模型收进同一个 base_url,切换模型、改价格、改延迟,不必动客户端。": "Bring OpenAI, Claude, Gemini, DeepSeek and dozens of other models behind a single base_url. Switch models, pricing, latency — no client changes.",
|
||||
"免费注册": "Sign up free",
|
||||
"联系销售": "Contact sales",
|
||||
"注册即送 ¥10": "¥10 free credits on signup",
|
||||
"无月费": "No monthly fee",
|
||||
"比开放透明": "Transparent pricing",
|
||||
"你的客户端只需一句": "Your client only needs one",
|
||||
",其余的事——路由、价格、容灾——都替你做了。": ", and we handle routing, pricing, and failover for you.",
|
||||
"立即接入。": "Integrate now.",
|
||||
"注册送 ¥10 试用金,分键跑完一组 benchmark,一拍压测。": "Get ¥10 trial credit on signup. Run a benchmark, then a load test.",
|
||||
"定价": "Pricing",
|
||||
"调用 AI 像调用": "Calling AI is as light as calling"
|
||||
}
|
||||
}
|
||||
|
|
@ -5554,6 +5554,21 @@
|
|||
"主题切换": "主题切换",
|
||||
"顶栏显示主题切换按钮,允许用户切换亮色/暗色模式": "顶栏显示主题切换按钮,允许用户切换亮色/暗色模式",
|
||||
"语言选择器": "语言选择器",
|
||||
"顶栏显示语言选择器,允许用户切换界面语言": "顶栏显示语言选择器,允许用户切换界面语言"
|
||||
"顶栏显示语言选择器,允许用户切换界面语言": "顶栏显示语言选择器,允许用户切换界面语言",
|
||||
"All systems normal": "All systems normal",
|
||||
"函数": "函数",
|
||||
"一样轻。": "一样轻。",
|
||||
"把 OpenAI、Claude、Gemini、DeepSeek 等几十个模型收进同一个 base_url,切换模型、改价格、改延迟,不必动客户端。": "把 OpenAI、Claude、Gemini、DeepSeek 等几十个模型收进同一个 base_url,切换模型、改价格、改延迟,不必动客户端。",
|
||||
"免费注册": "免费注册",
|
||||
"联系销售": "联系销售",
|
||||
"注册即送 ¥10": "注册即送 ¥10",
|
||||
"无月费": "无月费",
|
||||
"比开放透明": "比开放透明",
|
||||
"你的客户端只需一句": "你的客户端只需一句",
|
||||
",其余的事——路由、价格、容灾——都替你做了。": ",其余的事——路由、价格、容灾——都替你做了。",
|
||||
"立即接入。": "立即接入。",
|
||||
"注册送 ¥10 试用金,分键跑完一组 benchmark,一拍压测。": "注册送 ¥10 试用金,分键跑完一组 benchmark,一拍压测。",
|
||||
"定价": "定价",
|
||||
"调用 AI 像调用": "调用 AI 像调用"
|
||||
}
|
||||
}
|
||||
|
|
@ -1187,7 +1187,7 @@ html.dark .home-distributor-cta-btn {
|
|||
.distributor-apply-page-root {
|
||||
position: fixed;
|
||||
z-index: 1;
|
||||
top: 64px;
|
||||
top: 80px;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
|
|
|
|||
|
|
@ -133,7 +133,8 @@ const About = () => {
|
|||
);
|
||||
|
||||
return (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
|
||||
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
|
||||
{aboutLoaded && about === '' ? (
|
||||
<div className='flex justify-center items-center h-screen p-8'>
|
||||
<Empty
|
||||
|
|
@ -167,6 +168,7 @@ const About = () => {
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -308,16 +308,16 @@ const ApiTester = () => {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className='min-h-full bg-slate-50 pb-16 text-slate-900 dark:bg-neutral-950 dark:text-white'>
|
||||
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
|
||||
<div className='space-y-6 pt-8 pb-8'>
|
||||
<div className='min-h-full pb-16 text-slate-900 dark:text-white'>
|
||||
<div className='mx-auto w-full max-w-[1400px] px-4 pt-20 sm:px-6 lg:px-10'>
|
||||
<div className='space-y-8 pb-8'>
|
||||
{/* 页面标题区 */}
|
||||
<div className='flex flex-wrap items-end justify-between gap-4'>
|
||||
<div>
|
||||
<h1 className='text-3xl font-bold tracking-tight text-slate-900 md:text-4xl dark:text-white'>
|
||||
<h1 className='text-xl font-bold tracking-tight text-slate-900 md:text-2xl dark:text-white'>
|
||||
API 快速测试
|
||||
</h1>
|
||||
<p className='mt-2 text-sm text-slate-500 dark:text-white/50'>
|
||||
<p className='mt-2 text-xs text-slate-500 dark:text-white/50'>
|
||||
当前协议:{format === 'anthropic' ? 'Anthropic Messages' : 'OpenAI Chat Completions'} · 请求地址 {finalEndpoint}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -330,14 +330,14 @@ const ApiTester = () => {
|
|||
</div>
|
||||
|
||||
{/* 第一行:API Key / 测试目标 / 模型 */}
|
||||
<div className='grid grid-cols-1 gap-5 lg:grid-cols-3'>
|
||||
<div className='grid grid-cols-1 gap-6 lg:grid-cols-3'>
|
||||
{/* API Key 输入卡片 */}
|
||||
<div className='rounded-2xl border border-slate-200 bg-white p-5 transition-shadow hover:shadow-md dark:border-white/5 dark:bg-white/[0.02] dark:hover:shadow-black/20'>
|
||||
<div className='rounded-2xl border border-slate-200 bg-white p-6 transition-shadow hover:shadow-md dark:border-white/10 dark:bg-white/[0.02] dark:hover:shadow-black/20'>
|
||||
<div className='mb-3 flex items-center gap-2'>
|
||||
<div className='flex h-8 w-8 items-center justify-center rounded-lg bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400'>
|
||||
<KeyRound size={15} />
|
||||
</div>
|
||||
<div className='text-sm font-semibold text-slate-900 dark:text-white'>API Key</div>
|
||||
<div className='text-xs font-semibold text-slate-900 dark:text-white'>API Key</div>
|
||||
</div>
|
||||
<div className='relative'>
|
||||
<input
|
||||
|
|
@ -360,19 +360,19 @@ const ApiTester = () => {
|
|||
</div>
|
||||
|
||||
{/* 测试目标选择卡片 */}
|
||||
<div className='rounded-2xl border border-slate-200 bg-white p-5 transition-shadow hover:shadow-md dark:border-white/5 dark:bg-white/[0.02] dark:hover:shadow-black/20'>
|
||||
<div className='rounded-2xl border border-slate-200 bg-white p-6 transition-shadow hover:shadow-md dark:border-white/10 dark:bg-white/[0.02] dark:hover:shadow-black/20'>
|
||||
<div className='mb-3 flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='flex h-8 w-8 items-center justify-center rounded-lg bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400'>
|
||||
<Globe size={15} />
|
||||
</div>
|
||||
<div className='text-sm font-semibold text-slate-900 dark:text-white'>
|
||||
<div className='text-xs font-semibold text-slate-900 dark:text-white'>
|
||||
测试目标
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCustomBase((v) => !v)}
|
||||
className='inline-flex items-center gap-1 text-[11px] text-slate-400 transition-colors hover:text-slate-700 dark:text-white/40 dark:hover:text-white'
|
||||
className='inline-flex items-center gap-1 text-xs text-slate-400 transition-colors hover:text-slate-700 dark:text-white/40 dark:hover:text-white'
|
||||
title='切换自定义域名'
|
||||
>
|
||||
{showCustomBase ? '收起域名' : '换个平台'}
|
||||
|
|
@ -386,10 +386,10 @@ const ApiTester = () => {
|
|||
return acc;
|
||||
}, {});
|
||||
return (
|
||||
<div className='space-y-2.5'>
|
||||
<div className='space-y-3'>
|
||||
{Object.entries(grouped).map(([group, items]) => (
|
||||
<div key={group}>
|
||||
<div className='mb-1.5 text-[10px] font-medium uppercase tracking-wider text-slate-400 dark:text-white/40'>
|
||||
<div className='mb-1.5 text-[11px] font-medium uppercase tracking-wider text-slate-400 dark:text-white/40'>
|
||||
{group}
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-1.5'>
|
||||
|
|
@ -397,7 +397,7 @@ const ApiTester = () => {
|
|||
<button
|
||||
key={s.id}
|
||||
onClick={() => applyScene(s)}
|
||||
className='inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-[11px] font-medium text-slate-600 transition hover:border-blue-400 hover:bg-blue-50 hover:text-blue-600 dark:border-white/10 dark:bg-white/[0.02] dark:text-white/70 dark:hover:border-blue-400 dark:hover:bg-blue-500/10 dark:hover:text-blue-400'
|
||||
className='inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-600 transition hover:border-blue-400 hover:bg-blue-50 hover:text-blue-600 dark:border-white/10 dark:bg-white/[0.02] dark:text-white/70 dark:hover:border-blue-400 dark:hover:bg-blue-500/10 dark:hover:text-blue-400'
|
||||
>
|
||||
<s.Icon size={11} /> {s.label}
|
||||
</button>
|
||||
|
|
@ -408,12 +408,12 @@ const ApiTester = () => {
|
|||
|
||||
<div className='border-t border-slate-100 pt-2.5 dark:border-white/5'>
|
||||
<div className='mb-1.5 flex items-center justify-between'>
|
||||
<div className='text-[10px] font-medium uppercase tracking-wider text-slate-400 dark:text-white/40'>
|
||||
<div className='text-[11px] font-medium uppercase tracking-wider text-slate-400 dark:text-white/40'>
|
||||
我的目标
|
||||
</div>
|
||||
<button
|
||||
onClick={saveCurrentAsCustom}
|
||||
className='inline-flex items-center gap-1 rounded-full border border-slate-200 bg-white px-2.5 py-1 text-[10px] font-medium text-slate-600 transition hover:border-blue-400 hover:text-blue-600 dark:border-white/10 dark:bg-white/[0.02] dark:text-white/70 dark:hover:text-blue-400'
|
||||
className='inline-flex items-center gap-1 rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs font-medium text-slate-600 transition hover:border-blue-400 hover:text-blue-600 dark:border-white/10 dark:bg-white/[0.02] dark:text-white/70 dark:hover:text-blue-400'
|
||||
>
|
||||
<Plus size={10} /> 保存当前
|
||||
</button>
|
||||
|
|
@ -431,7 +431,7 @@ const ApiTester = () => {
|
|||
>
|
||||
<button
|
||||
onClick={() => applyScene(s)}
|
||||
className='inline-flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-medium text-slate-600 transition hover:bg-blue-50 hover:text-blue-600 dark:text-white/70 dark:hover:bg-blue-500/10 dark:hover:text-blue-400'
|
||||
className='inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-slate-600 transition hover:bg-blue-50 hover:text-blue-600 dark:text-white/70 dark:hover:bg-blue-500/10 dark:hover:text-blue-400'
|
||||
>
|
||||
<Bookmark size={11} /> {s.label}
|
||||
</button>
|
||||
|
|
@ -452,14 +452,14 @@ const ApiTester = () => {
|
|||
})()}
|
||||
|
||||
<div className='mt-3'>
|
||||
<label className='mb-1.5 block text-[11px] font-medium text-slate-500 dark:text-white/50'>
|
||||
<label className='mb-1.5 block text-xs font-medium text-slate-500 dark:text-white/50'>
|
||||
完整请求地址
|
||||
</label>
|
||||
<input
|
||||
value={isAbsoluteEndpoint ? endpointPath : `${endpointBase}${endpointPath}`}
|
||||
onChange={(e) => applyFullUrl(e.target.value)}
|
||||
placeholder='https://你的平台域名/v1/chat/completions'
|
||||
className='w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 font-mono text-[11px] text-slate-900 outline-none transition focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-500/20 dark:border-white/10 dark:bg-white/[0.04] dark:text-white dark:focus:border-blue-400 dark:focus:bg-white/[0.06]'
|
||||
className='w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 font-mono text-xs text-slate-900 outline-none transition focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-500/20 dark:border-white/10 dark:bg-white/[0.04] dark:text-white dark:focus:border-blue-400 dark:focus:bg-white/[0.06]'
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -487,22 +487,22 @@ const ApiTester = () => {
|
|||
</div>
|
||||
|
||||
{/* 模型选择卡片 */}
|
||||
<div className='rounded-2xl border border-slate-200 bg-white p-5 transition-shadow hover:shadow-md dark:border-white/5 dark:bg-white/[0.02] dark:hover:shadow-black/20'>
|
||||
<div className='rounded-2xl border border-slate-200 bg-white p-6 transition-shadow hover:shadow-md dark:border-white/10 dark:bg-white/[0.02] dark:hover:shadow-black/20'>
|
||||
<div className='mb-3 flex items-center gap-2'>
|
||||
<div className='flex h-8 w-8 items-center justify-center rounded-lg bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400'>
|
||||
<Cpu size={15} />
|
||||
</div>
|
||||
<div className='text-sm font-semibold text-slate-900 dark:text-white'>模型</div>
|
||||
<div className='text-xs font-semibold text-slate-900 dark:text-white'>模型</div>
|
||||
</div>
|
||||
<input
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
placeholder='例如 gpt-4o-mini / claude-3-5-sonnet ...'
|
||||
className='w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 font-mono text-xs text-slate-900 outline-none transition focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-500/20 dark:border-white/10 dark:bg-white/[0.04] dark:text-white dark:focus:border-blue-400 dark:focus:bg-white/[0.06]'
|
||||
className='w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 font-mono text-xs text-slate-900 outline-none transition focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-500/20 dark:border-white/10 dark:bg-white/[0.04] dark:text-white dark:focus:border-blue-400 dark:focus:bg-white/[0.06]'
|
||||
/>
|
||||
{format === 'anthropic' && (
|
||||
<div className='mt-3'>
|
||||
<label className='mb-1.5 block text-[11px] font-medium text-slate-500 dark:text-white/50'>
|
||||
<label className='mb-1.5 block text-xs font-medium text-slate-500 dark:text-white/50'>
|
||||
max_tokens(Anthropic 必填)
|
||||
</label>
|
||||
<input
|
||||
|
|
@ -511,7 +511,7 @@ const ApiTester = () => {
|
|||
max={8192}
|
||||
value={maxTokens}
|
||||
onChange={(e) => setMaxTokens(Number(e.target.value) || 1)}
|
||||
className='w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 font-mono text-xs text-slate-900 outline-none transition focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-500/20 dark:border-white/10 dark:bg-white/[0.04] dark:text-white dark:focus:border-blue-400 dark:focus:bg-white/[0.06]'
|
||||
className='w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 font-mono text-xs text-slate-900 outline-none transition focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-500/20 dark:border-white/10 dark:bg-white/[0.04] dark:text-white dark:focus:border-blue-400 dark:focus:bg-white/[0.06]'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -519,7 +519,7 @@ const ApiTester = () => {
|
|||
</div>
|
||||
|
||||
{/* 第二行:请求参数 + 响应结果 */}
|
||||
<div className='grid grid-cols-1 gap-5 lg:grid-cols-2'>
|
||||
<div className='grid grid-cols-1 gap-6 lg:grid-cols-2'>
|
||||
{/* 请求参数卡片 */}
|
||||
<div className='rounded-2xl border border-slate-200 bg-white p-6 transition-shadow hover:shadow-md dark:border-white/5 dark:bg-white/[0.02] dark:hover:shadow-black/20'>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
|
|
@ -527,7 +527,7 @@ const ApiTester = () => {
|
|||
<div className='flex h-8 w-8 items-center justify-center rounded-lg bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400'>
|
||||
<MessageSquare size={15} />
|
||||
</div>
|
||||
<div className='text-sm font-semibold text-slate-900 dark:text-white'>
|
||||
<div className='text-xs font-semibold text-slate-900 dark:text-white'>
|
||||
请求参数
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -547,7 +547,7 @@ const ApiTester = () => {
|
|||
|
||||
<div className='space-y-4'>
|
||||
<div>
|
||||
<label className='mb-1.5 block text-[11px] font-medium uppercase tracking-wider text-slate-500 dark:text-white/50'>
|
||||
<label className='mb-1.5 block text-xs font-medium uppercase tracking-wider text-slate-500 dark:text-white/50'>
|
||||
{format === 'anthropic' ? 'system(顶层字段)' : '系统提示'}
|
||||
</label>
|
||||
<textarea
|
||||
|
|
@ -555,12 +555,12 @@ const ApiTester = () => {
|
|||
onChange={(e) => setSystemPrompt(e.target.value)}
|
||||
rows={2}
|
||||
placeholder={format === 'anthropic' ? '(可选)Anthropic 顶层 system 字段' : '(可选)设置模型角色'}
|
||||
className='w-full resize-none rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-900 outline-none transition focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-500/20 dark:border-white/10 dark:bg-white/[0.04] dark:text-white dark:focus:border-blue-400 dark:focus:bg-white/[0.06]'
|
||||
className='w-full resize-none rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-xs text-slate-900 outline-none transition focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-500/20 dark:border-white/10 dark:bg-white/[0.04] dark:text-white dark:focus:border-blue-400 dark:focus:bg-white/[0.06]'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='mb-1.5 block text-[11px] font-medium uppercase tracking-wider text-slate-500 dark:text-white/50'>
|
||||
<label className='mb-1.5 block text-xs font-medium uppercase tracking-wider text-slate-500 dark:text-white/50'>
|
||||
用户提示 <span className='text-red-500'>*</span>
|
||||
</label>
|
||||
<textarea
|
||||
|
|
@ -568,7 +568,7 @@ const ApiTester = () => {
|
|||
onChange={(e) => setPrompt(e.target.value)}
|
||||
rows={5}
|
||||
placeholder='输入你想让模型回答的内容'
|
||||
className='w-full resize-none rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-900 outline-none transition focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-500/20 dark:border-white/10 dark:bg-white/[0.04] dark:text-white dark:focus:border-blue-400 dark:focus:bg-white/[0.06]'
|
||||
className='w-full resize-none rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-xs text-slate-900 outline-none transition focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-500/20 dark:border-white/10 dark:bg-white/[0.04] dark:text-white dark:focus:border-blue-400 dark:focus:bg-white/[0.06]'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -581,12 +581,12 @@ const ApiTester = () => {
|
|||
<div className='flex h-8 w-8 items-center justify-center rounded-lg bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400'>
|
||||
<Zap size={15} />
|
||||
</div>
|
||||
<div className='text-sm font-semibold text-slate-900 dark:text-white'>
|
||||
<div className='text-xs font-semibold text-slate-900 dark:text-white'>
|
||||
响应结果
|
||||
</div>
|
||||
{status && (
|
||||
<span
|
||||
className={`ml-2 inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[11px] font-medium ${
|
||||
className={`ml-2 inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||
status.ok
|
||||
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400'
|
||||
: 'bg-red-50 text-red-600 dark:bg-red-500/10 dark:text-red-400'
|
||||
|
|
@ -607,7 +607,7 @@ const ApiTester = () => {
|
|||
navigator.clipboard?.writeText(response);
|
||||
Toast.success('已复制响应');
|
||||
}}
|
||||
className='inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-[11px] font-medium text-slate-600 transition-colors hover:border-slate-300 hover:text-slate-900 dark:border-white/10 dark:bg-white/[0.02] dark:text-white/60 dark:hover:border-white/20 dark:hover:text-white'
|
||||
className='inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-600 transition-colors hover:border-slate-300 hover:text-slate-900 dark:border-white/10 dark:bg-white/[0.02] dark:text-white/60 dark:hover:border-white/20 dark:hover:text-white'
|
||||
>
|
||||
<Copy size={11} /> 复制
|
||||
</button>
|
||||
|
|
@ -621,7 +621,7 @@ const ApiTester = () => {
|
|||
请求中...
|
||||
</div>
|
||||
) : response ? (
|
||||
<pre className='max-h-[480px] overflow-auto p-4 font-mono text-[11px] leading-relaxed text-slate-800 dark:text-white/80'>
|
||||
<pre className='max-h-[480px] overflow-auto p-4 font-mono text-xs leading-relaxed text-slate-800 dark:text-white/80'>
|
||||
{response}
|
||||
</pre>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -38,9 +38,11 @@ const File = () => {
|
|||
}, [editId]);
|
||||
|
||||
return (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
|
||||
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
|
||||
<ChannelsTable />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,364 @@
|
|||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { ArrowRight, Mail, MessageSquare, Building2, ShieldCheck, Check } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const ACC = '#635BFF';
|
||||
const ACC_SOFT = '#EEEBFF';
|
||||
const BG = '#FFFFFF';
|
||||
const SOFT = '#FBFAF7';
|
||||
const TINT = '#F6F4FF';
|
||||
const INK = '#0A2540';
|
||||
const SUB = '#425466';
|
||||
const DIM = '#697386';
|
||||
const LINE = '#E3E8EE';
|
||||
|
||||
function Field({ label, value, onChange, type = 'text', required = false, placeholder = '' }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2 text-xs" style={{ color: DIM }}>
|
||||
{label}{required && <span style={{ color: ACC }}> *</span>}
|
||||
</div>
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
required={required}
|
||||
placeholder={placeholder}
|
||||
className="w-full rounded-lg border bg-white px-3.5 py-2.5 text-sm outline-none transition-colors focus:border-[#635BFF]"
|
||||
style={{ borderColor: LINE, color: INK }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoCard({ icon, title, primary, sub }) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl border bg-white p-5 transition-all hover:-translate-y-0.5 hover:shadow-sm"
|
||||
style={{ borderColor: LINE }}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg shrink-0"
|
||||
style={{ background: ACC_SOFT, color: ACC }}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="text-sm font-semibold tracking-tight" style={{ color: INK }}>{title}</div>
|
||||
</div>
|
||||
<div className="mt-3.5 pl-[42px]">
|
||||
<div className="text-[15px] font-medium" style={{ color: ACC }}>{primary}</div>
|
||||
<div className="mt-1 text-xs leading-[1.6]" style={{ color: DIM }}>{sub}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniTopBar() {
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-[#E3E8EE] bg-white/85 backdrop-blur-md">
|
||||
<div className="flex h-16 items-center px-6 lg:px-10 max-w-[1200px] mx-auto">
|
||||
<Link to="/" className="flex items-center gap-2.5">
|
||||
<img src="/logo.jpg" alt="logo" className="h-7 w-7 rounded-full" />
|
||||
<span className="font-heading text-[15px] font-semibold tracking-tight" style={{ color: INK }}>TuringToken</span>
|
||||
</Link>
|
||||
<div className="hidden md:flex items-center gap-2 ml-6 rounded-full px-2.5 py-1 text-[11px]" style={{ background: '#F6F9FC', color: DIM }}>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
|
||||
<span>服务正常 · 99.95%</span>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<Link
|
||||
to="/console"
|
||||
className="inline-flex h-9 items-center rounded-full px-3.5 text-sm hover:bg-[#0A254006]"
|
||||
style={{ color: SUB }}
|
||||
>
|
||||
控制台
|
||||
</Link>
|
||||
<Link
|
||||
to="/register"
|
||||
className="ml-2 inline-flex h-9 items-center gap-1.5 rounded-full px-4 text-sm font-medium text-white hover:opacity-90"
|
||||
style={{ background: INK }}
|
||||
>
|
||||
免费注册 <ArrowRight size={12} />
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Contact() {
|
||||
const [form, setForm] = useState({
|
||||
company: '',
|
||||
name: '',
|
||||
email: '',
|
||||
volume: '',
|
||||
message: '',
|
||||
});
|
||||
const update = (k) => (e) => setForm({ ...form, [k]: e.target.value });
|
||||
const submit = (e) => {
|
||||
e.preventDefault();
|
||||
alert('已收到,我们会在 24 小时内联系你。');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ background: BG, color: INK }}>
|
||||
<MiniTopBar />
|
||||
|
||||
{/* Hero */}
|
||||
<section className="relative overflow-hidden" style={{ background: BG }}>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-[600px]"
|
||||
style={{ background: 'radial-gradient(80% 60% at 50% 0%, #EEEBFF 0%, transparent 70%)' }}
|
||||
/>
|
||||
<div className="relative max-w-[1200px] mx-auto px-6 lg:px-10 pt-20 pb-14 lg:pt-28 lg:pb-20 text-center">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs" style={{ borderColor: LINE, color: DIM }}>
|
||||
<span className="h-1.5 w-1.5 rounded-full" style={{ background: ACC }} />
|
||||
<span>contact sales</span>
|
||||
</div>
|
||||
<h1
|
||||
className="mt-6 font-heading font-semibold leading-[1.04] tracking-[-0.025em]"
|
||||
style={{ fontSize: 'clamp(40px, 5.4vw, 72px)', color: INK }}
|
||||
>
|
||||
告诉我们,<br />
|
||||
你的 <span style={{ color: ACC }}>AI 场景</span>。
|
||||
</h1>
|
||||
<p className="mt-6 mx-auto max-w-2xl text-base leading-[1.65]" style={{ color: SUB }}>
|
||||
大客户、定制路由、长期合约、技术对接 — 都从这里开始。
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap items-center justify-center gap-6 text-xs" style={{ color: DIM }}>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Check size={12} style={{ color: ACC }} /> 24 小时内回复
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Check size={12} style={{ color: ACC }} /> 可签 NDA
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Check size={12} style={{ color: ACC }} /> 无电销骚扰
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Form + Info */}
|
||||
<section style={{ background: BG }}>
|
||||
<div className="max-w-[1200px] mx-auto px-6 lg:px-10 pb-24">
|
||||
<div className="grid gap-8 lg:grid-cols-[1.4fr_1fr]">
|
||||
{/* Form */}
|
||||
<form
|
||||
onSubmit={submit}
|
||||
className="rounded-2xl border bg-white p-8 lg:p-10 shadow-sm"
|
||||
style={{ borderColor: LINE }}
|
||||
>
|
||||
<div className="font-mono text-[11px] uppercase tracking-[0.25em]" style={{ color: DIM }}>get in touch</div>
|
||||
<h2
|
||||
className="mt-4 font-heading text-2xl font-semibold leading-[1.2] tracking-tight lg:text-[32px]"
|
||||
style={{ color: INK }}
|
||||
>
|
||||
填一下,<br />我们 24h 内联系你。
|
||||
</h2>
|
||||
|
||||
<div className="mt-8 grid gap-5 md:grid-cols-2">
|
||||
<Field label="公司名" value={form.company} onChange={update('company')} required />
|
||||
<Field label="你的姓名" value={form.name} onChange={update('name')} required />
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<Field
|
||||
label="工作邮箱"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={update('email')}
|
||||
required
|
||||
placeholder="you@company.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<div className="mb-2 text-xs" style={{ color: DIM }}>月调用量</div>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={form.volume}
|
||||
onChange={update('volume')}
|
||||
className="w-full appearance-none rounded-lg border bg-white pl-3.5 pr-9 py-2.5 text-sm outline-none transition-colors focus:border-[#635BFF]"
|
||||
style={{ borderColor: LINE, color: form.volume ? INK : DIM }}
|
||||
>
|
||||
<option value="">选择大致量级</option>
|
||||
<option>10 万以下</option>
|
||||
<option>10 万 – 100 万</option>
|
||||
<option>100 万 – 1000 万</option>
|
||||
<option>1000 万以上</option>
|
||||
<option>暂未确定</option>
|
||||
</select>
|
||||
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs" style={{ color: DIM }}>▾</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<div className="mb-2 text-xs" style={{ color: DIM }}>你的场景</div>
|
||||
<textarea
|
||||
value={form.message}
|
||||
onChange={update('message')}
|
||||
rows={4}
|
||||
placeholder="例如:做企业知识库客服,需要稳定的 Claude + 国产模型双备援,月调用量约 300 万..."
|
||||
className="w-full rounded-lg border bg-white px-3.5 py-2.5 text-sm outline-none transition-colors focus:border-[#635BFF] resize-y"
|
||||
style={{ borderColor: LINE, color: INK }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-7 flex flex-wrap items-center gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex h-11 items-center gap-2 rounded-full px-6 text-sm font-semibold text-white hover:opacity-90"
|
||||
style={{ background: INK }}
|
||||
>
|
||||
发送 <ArrowRight size={13} />
|
||||
</button>
|
||||
<span className="text-xs" style={{ color: DIM }}>
|
||||
提交即同意我们通过邮箱回访,仅一次
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Info side */}
|
||||
<div className="space-y-4">
|
||||
<InfoCard
|
||||
icon={<Mail size={16} />}
|
||||
title="销售咨询"
|
||||
primary="sales@turingtoken.ai"
|
||||
sub="工作日 24h 内回复;加急请电话"
|
||||
/>
|
||||
<InfoCard
|
||||
icon={<MessageSquare size={16} />}
|
||||
title="技术支持"
|
||||
primary="support@turingtoken.ai"
|
||||
sub="已签合约客户优先;P0 故障 1h 响应"
|
||||
/>
|
||||
<InfoCard
|
||||
icon={<ShieldCheck size={16} />}
|
||||
title="法务 / NDA"
|
||||
primary="legal@turingtoken.ai"
|
||||
sub="签前可先发 NDA 模板,含双向保密"
|
||||
/>
|
||||
<InfoCard
|
||||
icon={<Building2 size={16} />}
|
||||
title="办公室"
|
||||
primary="上海 · 北京 · 深圳"
|
||||
sub="仅预约接待,不接受 walk-in"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* FAQ */}
|
||||
<section style={{ background: SOFT }}>
|
||||
<div className="max-w-[1100px] mx-auto px-6 lg:px-10 py-24 lg:py-32">
|
||||
<div className="text-center">
|
||||
<div className="font-mono text-[11px] uppercase tracking-[0.25em]" style={{ color: DIM }}>frequently asked</div>
|
||||
<h2
|
||||
className="mt-4 font-heading text-3xl font-semibold tracking-tight lg:text-4xl"
|
||||
style={{ color: INK }}
|
||||
>
|
||||
常见问题。
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mt-14 grid gap-5 md:grid-cols-2">
|
||||
{[
|
||||
{
|
||||
q: '支持私有化部署吗?',
|
||||
a: '支持。签约后 2–4 周内完成,含多模型路由、监控面板、计费、权限管理、SSO。',
|
||||
},
|
||||
{
|
||||
q: '大客户有专属价格吗?',
|
||||
a: '月调用量 100 万以上可谈。多数客户能拿到比 OpenAI 直连便宜 30%+ 的合约价。',
|
||||
},
|
||||
{
|
||||
q: '能签长期合约吗?',
|
||||
a: '可以。年付 / 季付都支持,附 SLA 99.95% 兜底,故障按比例退款。',
|
||||
},
|
||||
{
|
||||
q: '数据合规怎么做?',
|
||||
a: '全链路 TLS 1.3;境内流量走国内供应商;日志 30 天滚动删除;支持 GDPR 与个保法。',
|
||||
},
|
||||
].map((f, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-xl border bg-white p-6"
|
||||
style={{ borderColor: LINE }}
|
||||
>
|
||||
<h3 className="font-heading text-lg font-semibold tracking-tight" style={{ color: INK }}>{f.q}</h3>
|
||||
<p className="mt-3 text-sm leading-[1.7]" style={{ color: SUB }}>{f.a}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Closing */}
|
||||
<section className="relative overflow-hidden" style={{ background: BG }}>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 h-[400px]"
|
||||
style={{ background: 'radial-gradient(60% 60% at 50% 100%, #EEEBFF 0%, transparent 70%)' }}
|
||||
/>
|
||||
<div className="relative mx-auto max-w-3xl px-6 lg:px-10 py-24 lg:py-32 text-center">
|
||||
<h2
|
||||
className="font-heading text-3xl font-semibold leading-tight tracking-[-0.02em] lg:text-[44px]"
|
||||
style={{ color: INK }}
|
||||
>
|
||||
先用起来,再聊。
|
||||
</h2>
|
||||
<p className="mt-5 text-base leading-[1.7]" style={{ color: SUB }}>
|
||||
注册送 ¥10 试跑金,足够跑完一组 benchmark、一轮压测。聊完再决定也不迟。
|
||||
</p>
|
||||
<div className="mt-9 flex flex-wrap items-center justify-center gap-3">
|
||||
<Link
|
||||
to="/register"
|
||||
className="inline-flex h-11 items-center gap-2 rounded-full px-6 text-sm font-semibold text-white hover:opacity-90"
|
||||
style={{ background: INK }}
|
||||
>
|
||||
免费注册 <ArrowRight size={13} />
|
||||
</Link>
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex h-11 items-center gap-1.5 rounded-full border px-6 text-sm hover:bg-[#0A254006]"
|
||||
style={{ borderColor: INK, color: INK }}
|
||||
>
|
||||
先看首页
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t" style={{ borderColor: LINE, background: BG }}>
|
||||
<div className="max-w-[1200px] mx-auto px-6 lg:px-10 py-12">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Link to="/" className="inline-flex items-center gap-2.5">
|
||||
<img src="/logo.jpg" alt="logo" className="h-7 w-7 rounded-full" />
|
||||
<span className="font-heading text-base font-semibold tracking-tight" style={{ color: INK }}>TuringToken</span>
|
||||
</Link>
|
||||
<span className="text-xs" style={{ color: DIM }}>© 2026 TuringToken · Made for developers.</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -21,9 +21,11 @@ import React from 'react';
|
|||
import Dashboard from '../../components/dashboard';
|
||||
|
||||
const Detail = () => (
|
||||
<div className='mt-[60px] w-full max-w-[1600px] mx-auto px-3 sm:px-4 lg:px-6 pb-6'>
|
||||
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
|
||||
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
|
||||
<Dashboard />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Detail;
|
||||
|
|
|
|||
|
|
@ -1128,7 +1128,8 @@ export default function DistributorAdmin() {
|
|||
);
|
||||
|
||||
return (
|
||||
<div className='mt-[60px] px-2 pb-16'>
|
||||
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
|
||||
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
|
||||
<Typography.Title heading={3}>{t('代理管理中心')}</Typography.Title>
|
||||
|
||||
<Tabs
|
||||
|
|
@ -1976,5 +1977,6 @@ export default function DistributorAdmin() {
|
|||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -268,7 +268,7 @@ export default function DistributorApply() {
|
|||
|
||||
if (isDist) {
|
||||
return (
|
||||
<div className='distributor-apply-page-root'>
|
||||
<div className='distributor-apply-page-root text-slate-900 dark:text-white'>
|
||||
<div className='w-full max-w-3xl px-1'>
|
||||
<ApplyStatusNotice variant='success' className='w-full'>
|
||||
{t('您已是代理,无需再次申请')}
|
||||
|
|
|
|||
|
|
@ -668,7 +668,8 @@ export default function DistributorCenter() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className='mt-14 px-4 pb-16 max-w-7xl mx-auto'>
|
||||
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
|
||||
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
|
||||
<Title heading={3} className='mb-8'>
|
||||
{t('代理分销')}
|
||||
</Title>
|
||||
|
|
@ -1197,5 +1198,6 @@ export default function DistributorCenter() {
|
|||
) : null}
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -21,9 +21,11 @@ import React from 'react';
|
|||
import UsageLogsTable from '../../components/table/usage-logs';
|
||||
|
||||
const Token = () => (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
|
||||
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
|
||||
<UsageLogsTable />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Token;
|
||||
|
|
|
|||
|
|
@ -21,9 +21,11 @@ import React from 'react';
|
|||
import MjLogsTable from '../../components/table/mj-logs';
|
||||
|
||||
const Midjourney = () => (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
|
||||
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
|
||||
<MjLogsTable />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Midjourney;
|
||||
|
|
|
|||
|
|
@ -36,9 +36,11 @@ const ModelPage = () => {
|
|||
}, [editId, modelName]);
|
||||
|
||||
return (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
|
||||
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
|
||||
<ModelsTable />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,231 @@
|
|||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Table,
|
||||
Select,
|
||||
Tag,
|
||||
Typography,
|
||||
Card,
|
||||
Button,
|
||||
Empty,
|
||||
Toast,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconDelete, IconPlus } from '@douyinfe/semi-icons';
|
||||
import { API, renderQuota, isAdmin } from '../../helpers';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const MAX_COMPARE = 4;
|
||||
|
||||
const endpointLabels = {
|
||||
chat: '对话',
|
||||
image: '图片',
|
||||
audio: '音频',
|
||||
video: '视频',
|
||||
embeddings: '嵌入',
|
||||
rerank: '重排',
|
||||
moderate: '审核',
|
||||
};
|
||||
|
||||
const ModelComparison = () => {
|
||||
const { t } = useTranslation();
|
||||
const [models, setModels] = useState([]);
|
||||
const [selected, setSelected] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get('/api/pricing', { disableDuplicate: true });
|
||||
if (res?.data?.success && Array.isArray(res.data.data)) {
|
||||
setModels(res.data.data);
|
||||
}
|
||||
} catch {
|
||||
Toast.error({ content: t('加载模型列表失败') });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [t]);
|
||||
|
||||
const modelOptions = useMemo(
|
||||
() =>
|
||||
models.map((m) => ({
|
||||
value: m.model_name,
|
||||
label: m.model_name,
|
||||
})),
|
||||
[models],
|
||||
);
|
||||
|
||||
const selectedModels = useMemo(
|
||||
() => models.filter((m) => selected.includes(m.model_name)),
|
||||
[models, selected],
|
||||
);
|
||||
|
||||
const handleSelect = (vals) => {
|
||||
if (vals.length > MAX_COMPARE) {
|
||||
Toast.warning({ content: t('最多同时对比 {{max}} 个模型', { max: MAX_COMPARE }) });
|
||||
return;
|
||||
}
|
||||
setSelected(vals);
|
||||
};
|
||||
|
||||
const handleRemove = (name) => {
|
||||
setSelected((prev) => prev.filter((n) => n !== name));
|
||||
};
|
||||
|
||||
const formatEndpointTypes = (types) => {
|
||||
if (!Array.isArray(types) || types.length === 0) return '-';
|
||||
return types.map((ep) => endpointLabels[ep] || ep).join('、');
|
||||
};
|
||||
|
||||
const formatTags = (tags) => {
|
||||
if (!tags) return '-';
|
||||
const arr = typeof tags === 'string' ? tags.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
||||
return arr.length > 0 ? arr.join('、') : '-';
|
||||
};
|
||||
|
||||
const formatPrice = (model) => {
|
||||
const ratio = Number(model.model_ratio || 0);
|
||||
const completionRatio = model.completion_ratio != null
|
||||
? Number(model.completion_ratio)
|
||||
: ratio;
|
||||
if (Number(model.model_price || 0) > 0) {
|
||||
return `$${Number(model.model_price).toFixed(4)} (固定价)`;
|
||||
}
|
||||
return `输入 ${ratio.toFixed(1)}x / 输出 ${completionRatio.toFixed(1)}x`;
|
||||
};
|
||||
|
||||
// Comparison rows
|
||||
const compareRows = [
|
||||
{ key: 'vendor', label: t('供应商'), render: (m) => m.owner_by || '-' },
|
||||
{ key: 'description', label: t('简介'), render: (m) => m.description || '-' },
|
||||
{ key: 'pricing', label: t('价格'), render: (m) => formatPrice(m) },
|
||||
{ key: 'endpoints', label: t('支持功能'), render: (m) => formatEndpointTypes(m.supported_endpoint_types) },
|
||||
{ key: 'tags', label: t('标签'), render: (m) => formatTags(m.tags) },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
|
||||
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
|
||||
<div className='mb-6 flex items-center justify-between'>
|
||||
<Title heading={3} className='!mb-0'>
|
||||
{t('模型对比')}
|
||||
</Title>
|
||||
<Text className='!text-semi-color-text-2'>
|
||||
{t('已选 {{n}} / {{max}}', { n: selected.length, max: MAX_COMPARE })}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Card className='mb-6'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<IconPlus className='text-semi-color-text-2' />
|
||||
<Select
|
||||
placeholder={t('选择模型进行对比(最多 {{max}} 个)', { max: MAX_COMPARE })}
|
||||
multiple
|
||||
value={selected}
|
||||
onChange={handleSelect}
|
||||
optionList={modelOptions}
|
||||
filter
|
||||
style={{ flex: 1, minWidth: 300 }}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
{selected.length > 0 && (
|
||||
<div className='mt-3 flex flex-wrap gap-2'>
|
||||
{selected.map((name) => (
|
||||
<Tag
|
||||
key={name}
|
||||
color='blue'
|
||||
closable
|
||||
onClose={() => handleRemove(name)}
|
||||
>
|
||||
{name}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{selectedModels.length > 0 ? (
|
||||
<Card>
|
||||
<div className='overflow-x-auto'>
|
||||
<table className='w-full border-collapse text-sm'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className='sticky left-0 z-10 bg-semi-color-bg-2 dark:bg-gray-900 px-4 py-3 text-left font-medium text-semi-color-text-2 border-b border-semi-color-border w-[120px]'>
|
||||
{t('属性')}
|
||||
</th>
|
||||
{selectedModels.map((m) => (
|
||||
<th
|
||||
key={m.model_name}
|
||||
className='px-4 py-3 text-left font-semibold text-semi-color-text-0 border-b border-semi-color-border min-w-[180px]'
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Text strong>{m.model_name}</Text>
|
||||
<Button
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
icon={<IconDelete />}
|
||||
onClick={() => handleRemove(m.model_name)}
|
||||
/>
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{compareRows.map((row) => (
|
||||
<tr key={row.key}>
|
||||
<td className='sticky left-0 z-10 bg-semi-color-bg-2 dark:bg-gray-900 px-4 py-3 font-medium text-semi-color-text-2 border-b border-semi-color-border'>
|
||||
{row.label}
|
||||
</td>
|
||||
{selectedModels.map((m) => (
|
||||
<td
|
||||
key={m.model_name}
|
||||
className='px-4 py-3 text-semi-color-text-0 border-b border-semi-color-border'
|
||||
>
|
||||
{row.render(m)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<Empty
|
||||
title={t('请选择模型')}
|
||||
description={t('在上方选择 2-4 个模型进行并排对比')}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelComparison;
|
||||
|
|
@ -41,9 +41,11 @@ const ModelDeploymentPage = () => {
|
|||
connectionError={connectionError}
|
||||
onRetry={() => testConnection()}
|
||||
>
|
||||
<div className='mt-[60px] px-2'>
|
||||
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
|
||||
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
|
||||
<DeploymentsTable />
|
||||
</div>
|
||||
</div>
|
||||
</DeploymentAccessGuard>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -22,9 +22,11 @@ import CombinedHeatConfig from './CombinedHeatConfig';
|
|||
|
||||
const ModelHeat = () => {
|
||||
return (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
|
||||
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
|
||||
<CombinedHeatConfig />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -85,6 +85,10 @@ const OperationLog = () => {
|
|||
const [detailVisible, setDetailVisible] = useState(false);
|
||||
const [selectedLog, setSelectedLog] = useState(null);
|
||||
|
||||
// Cleanup modal
|
||||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||
const [deleteDate, setDeleteDate] = useState(null);
|
||||
|
||||
const fetchLogs = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
|
|
@ -132,14 +136,17 @@ const OperationLog = () => {
|
|||
fetchLogs();
|
||||
};
|
||||
|
||||
const handleDeleteOldLogs = async () => {
|
||||
if (!dateRange || !dateRange[1]) {
|
||||
const handleDeleteOldLogs = () => {
|
||||
setDeleteDate(null);
|
||||
setDeleteModalVisible(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
if (!deleteDate) {
|
||||
showError('请选择截止日期');
|
||||
return;
|
||||
}
|
||||
const targetTimestamp = Math.floor(
|
||||
new Date(dateRange[1]).getTime() / 1000
|
||||
);
|
||||
const targetTimestamp = Math.floor(new Date(deleteDate).getTime() / 1000);
|
||||
Modal.confirm({
|
||||
title: '确认清理',
|
||||
content: `确定要清理 ${timestamp2string(targetTimestamp)} 之前的操作日志吗?此操作不可逆。`,
|
||||
|
|
@ -151,6 +158,7 @@ const OperationLog = () => {
|
|||
const { success, data, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(`成功清理 ${data} 条操作日志`);
|
||||
setDeleteModalVisible(false);
|
||||
fetchLogs();
|
||||
} else {
|
||||
showError(message);
|
||||
|
|
@ -206,7 +214,7 @@ const OperationLog = () => {
|
|||
dataIndex: 'target_name',
|
||||
key: 'target_name',
|
||||
width: 160,
|
||||
render: (text, record) => text || `ID: ${record.target_id}`,
|
||||
render: (text, record) => text || `ID:${record.target_id}`,
|
||||
},
|
||||
{
|
||||
title: t('操作描述'),
|
||||
|
|
@ -239,7 +247,8 @@ const OperationLog = () => {
|
|||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
|
||||
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
|
||||
<Card>
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -338,6 +347,27 @@ const OperationLog = () => {
|
|||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={t('清理日志')}
|
||||
visible={deleteModalVisible}
|
||||
onCancel={() => setDeleteModalVisible(false)}
|
||||
onOk={handleConfirmDelete}
|
||||
okText={t('确认清理')}
|
||||
cancelText={t('取消')}
|
||||
width={420}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<Text>{t('选择截止日期,将清理该日期之前的所有操作日志。此操作不可逆。')}</Text>
|
||||
<DatePicker
|
||||
type='dateTime'
|
||||
placeholder={t('选择截止日期')}
|
||||
value={deleteDate}
|
||||
onChange={setDeleteDate}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={t('操作详情')}
|
||||
visible={detailVisible}
|
||||
|
|
@ -402,6 +432,7 @@ const OperationLog = () => {
|
|||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -22,9 +22,11 @@ import RedemptionsTable from '../../components/table/redemptions';
|
|||
|
||||
const Redemption = () => {
|
||||
return (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
|
||||
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
|
||||
<RedemptionsTable />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -217,7 +217,8 @@ const Setting = () => {
|
|||
}
|
||||
}, [location.search]);
|
||||
return (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
|
||||
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
|
||||
<Layout>
|
||||
<Layout.Content>
|
||||
<Tabs
|
||||
|
|
@ -235,6 +236,7 @@ const Setting = () => {
|
|||
</Layout.Content>
|
||||
</Layout>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -22,9 +22,11 @@ import SubscriptionsPage from '../../components/table/subscriptions';
|
|||
|
||||
const Subscription = () => {
|
||||
return (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
|
||||
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
|
||||
<SubscriptionsPage />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -112,8 +112,8 @@ const SupplierApplyPage = () => {
|
|||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
className='mt-[60px] px-2 flex items-center justify-center'
|
||||
style={{ minHeight: 'calc(100vh - 360px)' }}
|
||||
className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white flex items-center justify-center'
|
||||
style={{ minHeight: 'calc(100vh - 200px)' }}
|
||||
>
|
||||
<Spin size='large' />
|
||||
</div>
|
||||
|
|
@ -121,7 +121,8 @@ const SupplierApplyPage = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
|
||||
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
|
||||
<SupplierApplicationModal
|
||||
visible={showApplicationModal}
|
||||
handleClose={handleModalClose}
|
||||
|
|
@ -221,6 +222,7 @@ const SupplierApplyPage = () => {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,8 @@ const SupplierChannelContent = () => {
|
|||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
|
||||
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
|
||||
{/* Modals */}
|
||||
<ColumnSelectorModal {...channelsData} />
|
||||
<EditTagModal
|
||||
|
|
@ -117,6 +118,7 @@ const SupplierChannelContent = () => {
|
|||
<ChannelsTable {...channelsData} />
|
||||
</CardPro>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -126,7 +128,8 @@ const SupplierChannelPage = () => {
|
|||
|
||||
if (!isSupplier()) {
|
||||
return (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
|
||||
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
|
||||
<div
|
||||
className='flex items-center justify-center'
|
||||
style={{ minHeight: 'calc(100vh - 360px)' }}
|
||||
|
|
@ -153,6 +156,7 @@ const SupplierChannelPage = () => {
|
|||
</Empty>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -282,7 +282,8 @@ export default function SupplierDashboardPage() {
|
|||
|
||||
if (!canAccess) {
|
||||
return (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
|
||||
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
|
||||
<div
|
||||
className='flex items-center justify-center'
|
||||
style={{ minHeight: 'calc(100vh - 360px)' }}
|
||||
|
|
@ -308,11 +309,13 @@ export default function SupplierDashboardPage() {
|
|||
</Empty>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='mt-[60px] w-full max-w-[1600px] mx-auto px-3 sm:px-4 lg:px-6 pb-6'>
|
||||
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
|
||||
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
|
||||
<Spin spinning={loading} size='large'>
|
||||
<div className='flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between mb-3'>
|
||||
<div className='flex items-center gap-2 min-w-0'>
|
||||
|
|
@ -451,5 +454,6 @@ export default function SupplierDashboardPage() {
|
|||
</Card>
|
||||
</Spin>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -157,7 +157,8 @@ const SupplierPricingSettingsContent = () => {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
|
||||
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
|
||||
<Spin spinning={loading} size='large'>
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<Tabs type='card' defaultActiveKey='visual'>
|
||||
|
|
@ -178,6 +179,7 @@ const SupplierPricingSettingsContent = () => {
|
|||
</Card>
|
||||
</Spin>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -190,7 +192,8 @@ const PricingSettingsPage = () => {
|
|||
|
||||
if (!isSupplier()) {
|
||||
return (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
|
||||
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
|
||||
<div
|
||||
className='flex items-center justify-center'
|
||||
style={{ minHeight: 'calc(100vh - 360px)' }}
|
||||
|
|
@ -217,6 +220,7 @@ const PricingSettingsPage = () => {
|
|||
</Empty>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,9 +22,11 @@ import SupplierApplicationsPage from '../../../components/table/supplier-applica
|
|||
|
||||
const SupplierApplication = () => {
|
||||
return (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
|
||||
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
|
||||
<SupplierApplicationsPage />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -22,9 +22,11 @@ import SuppliersPage from '../../../components/table/suppliers';
|
|||
|
||||
const Suppliers = () => {
|
||||
return (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
|
||||
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
|
||||
<SuppliersPage />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -21,9 +21,11 @@ import React from 'react';
|
|||
import TaskLogsTable from '../../components/table/task-logs';
|
||||
|
||||
const Task = () => (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
|
||||
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
|
||||
<TaskLogsTable />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Task;
|
||||
|
|
|
|||
|
|
@ -22,9 +22,11 @@ import TokensTable from '../../components/table/tokens';
|
|||
|
||||
const Token = () => {
|
||||
return (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
|
||||
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
|
||||
<TokensTable />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -22,9 +22,11 @@ import UsersTable from '../../components/table/users';
|
|||
|
||||
const User = () => {
|
||||
return (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<div className='min-h-full pb-16 pt-20 text-slate-900 dark:text-white'>
|
||||
<div className='mx-auto w-full max-w-[1400px] px-4 sm:px-6 lg:px-10'>
|
||||
<UsersTable />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export default defineConfig({
|
|||
}),
|
||||
],
|
||||
optimizeDeps: {
|
||||
force: true,
|
||||
include: ['date-fns/locale'],
|
||||
esbuildOptions: {
|
||||
loader: {
|
||||
'.js': 'jsx',
|
||||
|
|
|
|||
Loading…
Reference in New Issue