sync: update dist-site from subtree

This commit is contained in:
abingyyds committed 2026-04-29 16:54:50 +08:00
commit 423911d367
25 files changed
+2320 -232

No files matched your search

+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Noto+Sans+SC:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<title>AI Platform</title>
<script type="module" crossorigin src="/assets/index-CgGPBQ4H.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B3G1pDJs.css">
<script type="module" crossorigin src="/assets/index-By8pKJte.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DJ3hXS61.css">
</head>
<body class="antialiased">
<script>
+2
View File
@@ -1,6 +1,7 @@
import React, { lazy, Suspense } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import AuthGuard from './components/AuthGuard';
import NotificationBell from './components/NotificationBell';
import { ThemeProvider, useTheme } from './context/ThemeContext';
const Login = lazy(() => import('./pages/Login'));
@@ -46,6 +47,7 @@ function ThemedRoutes() {
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<NotificationBell />
</Suspense>
);
}
+17 -5
View File
@@ -19,11 +19,18 @@ api.interceptors.request.use((config) => {
return config;
});
const shouldSkipErrorHandler = (config) => Boolean(config?.skipErrorHandler);
// Global error handler
api.interceptors.response.use(
(res) => {
// Handle success:false responses with user-visible errors
if (res.data && res.data.success === false && res.data.message) {
if (
res.data &&
res.data.success === false &&
res.data.message &&
!shouldSkipErrorHandler(res.config)
) {
toast.error(res.data.message);
}
return res;
@@ -34,8 +41,10 @@ api.interceptors.response.use(
localStorage.removeItem('dist_user_id');
// Emit event so AuthContext can clear React state
window.dispatchEvent(new Event('auth:logout'));
toast.error('Session expired, please log in again');
} else {
if (!shouldSkipErrorHandler(err.config)) {
toast.error('Session expired, please log in again');
}
} else if (!shouldSkipErrorHandler(err.config)) {
toast.error(msg);
}
return Promise.reject(err);
@@ -48,6 +57,7 @@ export const getSiteModels = () => api.get('/api/dist/site/models');
export const getSitePricing = () => api.get('/api/dist/site/pricing');
export const getSitePackages = () => api.get('/api/dist/site/packages');
export const getSiteKeyGroups = () => api.get('/api/dist/site/key-groups');
export const getSiteKeyGroupPricing = (id) => api.get(`/api/dist/site/key-groups/${id}/pricing`);
export const getSubDistributorInfo = () => api.get('/api/dist/site/sub-distributor/info');
// ===== Auth =====
@@ -56,12 +66,13 @@ export const login = (data) => api.post('/api/dist/user/login', data);
export const logout = () => api.post('/api/dist/user/logout');
// ===== User =====
export const getUserSelf = () => api.get('/api/dist/user/self');
export const getUserSelf = (config) => api.get('/api/dist/user/self', config);
export const getUserUsage = () => api.get('/api/dist/user/usage');
export const getUserLogs = (params) => api.get('/api/dist/user/logs', { params });
// ===== Tokens =====
export const getTokens = () => api.get('/api/dist/token/list');
export const getTokenSupportedModels = (id) => api.get(`/api/dist/token/${id}/models`);
export const createToken = (data) => api.post('/api/dist/token/create', data);
export const updateToken = (id, data) => api.put(`/api/dist/token/${id}`, data);
export const deleteToken = (id) => api.delete(`/api/dist/token/${id}`);
@@ -69,7 +80,8 @@ export const deleteToken = (id) => api.delete(`/api/dist/token/${id}`);
// ===== Purchase =====
export const redeemCode = (key) => api.post('/api/dist/topup/redeem', { key }); // backend field is "key"
export const subscribePackage = (packageId) => api.post('/api/dist/package/subscribe', { package_id: packageId });
export const getActiveSubscriptions = () => api.get('/api/dist/package/subscriptions');
export const getActiveSubscriptions = (config) =>
api.get('/api/dist/package/subscriptions', config);
// ===== Online Topup =====
export const getTopupInfo = () => api.get('/api/dist/topup/info');
+118
View File
@@ -0,0 +1,118 @@
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import toast from 'react-hot-toast';
import { useSite } from '../context/SiteContext';
const SHARED_API_ENDPOINTS = [
{
id: 'world',
labelKey: 'home.apiEndpointWorld',
url: 'https://world.claudecoder.me',
},
{
id: 'china',
labelKey: 'home.apiEndpointChina',
url: 'https://china.claudecoder.me',
},
];
const normalizeEndpoint = (value = '') => {
const raw = String(value || '').trim();
if (!raw) return '';
if (/^https?:\/\//i.test(raw)) {
return raw.replace(/\/+$/, '');
}
return `https://${raw.replace(/^\/+/, '').replace(/\/+$/, '')}`;
};
const copyToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text);
} catch (e) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
};
export default function ApiEndpoints() {
const { t } = useTranslation();
const { site } = useSite();
const siteEndpoint = useMemo(() => {
const currentOrigin =
typeof window !== 'undefined' ? window.location.origin : '';
return normalizeEndpoint(site?.domain || currentOrigin);
}, [site?.domain]);
const endpoints = useMemo(
() => [
{
id: 'site',
label: t('home.apiEndpointSite'),
url: siteEndpoint,
},
...SHARED_API_ENDPOINTS.map((endpoint) => ({
...endpoint,
label: t(endpoint.labelKey),
apiOnly: true,
})),
].filter((endpoint) => endpoint.url),
[siteEndpoint, t],
);
const handleCopy = async (url) => {
await copyToClipboard(url);
toast.success(t('config.apiUrlCopied'));
};
return (
<section className="max-w-5xl mx-auto px-6 pb-12">
<div className="glass rounded-2xl p-5 md:p-6">
<div className="mb-4 flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
<div>
<p className="text-sm font-semibold text-page">
{t('home.apiEndpointsTitle')}
</p>
<p className="mt-1 text-xs text-page-muted">
{t('home.apiEndpointsDesc')}
</p>
</div>
<span className="text-[11px] text-page-muted">
{t('home.apiEndpointClickToCopy')}
</span>
</div>
<div className="grid gap-3 md:grid-cols-3">
{endpoints.map((endpoint) => (
<button
key={endpoint.id}
type="button"
onClick={() => handleCopy(endpoint.url)}
className="rounded-xl border border-page-divider bg-page-inset/40 px-4 py-3 text-left transition-colors hover:bg-page-surface-hover"
>
<div className="mb-1 flex items-center gap-2">
<span className="text-xs font-medium text-page-label">
{endpoint.label}
</span>
{endpoint.apiOnly && (
<span className="rounded-full bg-brand-500/10 px-2 py-0.5 text-[10px] font-medium text-brand-400">
{t('home.apiEndpointApiOnly')}
</span>
)}
</div>
<code className="block break-all text-[11px] leading-relaxed text-page-secondary">
{endpoint.url}
</code>
</button>
))}
</div>
</div>
</section>
);
}
+999
View File
@@ -0,0 +1,999 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import toast from 'react-hot-toast';
import { getTokenSupportedModels } from '../api';
import { useSite } from '../context/SiteContext';
const TOOLS = [
{ id: 'claudecode', name: 'Claude Code', path: '~/.claude/settings.json' },
{ id: 'hermes', name: 'Hermes', path: 'hermes-subrouter.sh' },
{ id: 'openclaw', name: 'OpenClaw', path: '~/.openclaw/openclaw.json' },
{ id: 'opencode', name: 'OpenCode', path: '~/.config/opencode/opencode.json' },
{ id: 'cursor', name: 'Cursor', path: 'Settings -> Models -> OpenAI API Key' },
{ id: 'curl', name: 'cURL', path: 'Terminal' },
{ id: 'python', name: 'Python SDK', path: 'main.py' },
{ id: 'anthropic', name: 'Anthropic SDK', path: 'main.py' },
];
const CCSWITCH_APPS = [
{ id: 'codex', name: 'Codex', endpointType: 'openai' },
{ id: 'claude', name: 'Claude Code', endpointType: 'anthropic' },
{ id: 'opencode', name: 'OpenCode', endpointType: 'openai' },
{ id: 'openclaw', name: 'OpenClaw', endpointType: 'openclaw' },
];
const CCSWITCH_RELEASE_URL =
'https://github.com/farion1231/cc-switch/releases/latest';
const CCSWITCH_REPO_URL = 'https://github.com/farion1231/cc-switch';
function ThemedSelect({
value,
onChange,
options,
placeholder,
disabled = false,
renderValue,
renderOption,
emptyLabel,
}) {
const [open, setOpen] = useState(false);
const rootRef = useRef(null);
useEffect(() => {
if (disabled) {
setOpen(false);
}
}, [disabled]);
useEffect(() => {
if (!open) return undefined;
const handlePointerDown = (event) => {
if (!rootRef.current?.contains(event.target)) {
setOpen(false);
}
};
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
setOpen(false);
}
};
document.addEventListener('mousedown', handlePointerDown);
document.addEventListener('touchstart', handlePointerDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handlePointerDown);
document.removeEventListener('touchstart', handlePointerDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [open]);
const selectedOption = options.find((option) => option.value === value);
const displayLabel = selectedOption
? (renderValue ? renderValue(selectedOption) : selectedOption.label)
: placeholder;
return (
<div ref={rootRef} className="relative">
<button
type="button"
className="input input-solid flex items-center justify-between gap-3 text-left disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => !disabled && setOpen((prev) => !prev)}
aria-haspopup="listbox"
aria-expanded={open}
disabled={disabled}
>
<span
className={`block truncate ${
selectedOption ? 'text-page' : 'text-page-muted'
}`}
>
{displayLabel}
</span>
<svg
className={`h-4 w-4 flex-shrink-0 text-page-muted transition-transform ${
open ? 'rotate-180' : ''
}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{open && (
<div className="select-panel absolute left-0 right-0 top-full z-30 mt-2 overflow-hidden rounded-xl">
<div className="max-h-72 overflow-y-auto p-1.5" role="listbox">
{options.length > 0 ? (
options.map((option) => {
const selected = option.value === value;
return (
<button
key={option.value}
type="button"
className={`flex w-full items-center justify-between gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
selected
? 'bg-brand-500/15 text-page'
: 'text-page-secondary hover:bg-page-surface-hover hover:text-page'
}`}
onClick={() => {
onChange(option.value);
setOpen(false);
}}
role="option"
aria-selected={selected}
>
<span className="min-w-0 flex-1">
{renderOption ? renderOption(option) : option.label}
</span>
{selected && (
<svg
className="h-4 w-4 flex-shrink-0 text-brand-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
)}
</button>
);
})
) : (
<div className="px-3 py-2 text-sm text-page-muted">
{emptyLabel}
</div>
)}
</div>
</div>
)}
</div>
);
}
const ConfigExporter = ({ tokens = [] }) => {
const { t } = useTranslation();
const { site } = useSite();
const [selectedTokenId, setSelectedTokenId] = useState(null);
const [availableModels, setAvailableModels] = useState([]);
const [selectedModel, setSelectedModel] = useState('');
const [selectedTool, setSelectedTool] = useState('claudecode');
const [selectedCCSwitchApp, setSelectedCCSwitchApp] = useState('codex');
const [loadingModels, setLoadingModels] = useState(false);
const [modelsError, setModelsError] = useState(false);
const [copied, setCopied] = useState(false);
const [launchingCCSwitch, setLaunchingCCSwitch] = useState(false);
const [showCCSwitchDownload, setShowCCSwitchDownload] = useState(false);
const serverAddress = window.location.origin;
const selectedToken = useMemo(
() => tokens.find((token) => token.id === selectedTokenId) || null,
[tokens, selectedTokenId],
);
const selectedToolMeta = useMemo(
() => TOOLS.find((tool) => tool.id === selectedTool) || TOOLS[0],
[selectedTool],
);
const tokenOptions = useMemo(
() =>
tokens.map((token) => ({
value: token.id,
label: `${token.name} (sk-${token.key.substring(0, 16)}...)`,
token,
})),
[tokens],
);
const modelOptions = useMemo(
() =>
availableModels.map((model) => ({
value: model,
label: model,
})),
[availableModels],
);
useEffect(() => {
if (tokens.length === 0) {
setSelectedTokenId(null);
setAvailableModels([]);
setSelectedModel('');
return;
}
const stillExists = tokens.some((token) => token.id === selectedTokenId);
if (stillExists) return;
const preferred = tokens.find((token) => token.status === 1) || tokens[0];
setSelectedTokenId(preferred.id);
}, [tokens, selectedTokenId]);
useEffect(() => {
if (!selectedToken?.id) {
setAvailableModels([]);
setSelectedModel('');
return;
}
let cancelled = false;
const loadModels = async () => {
setLoadingModels(true);
setModelsError(false);
try {
const res = await getTokenSupportedModels(selectedToken.id);
if (cancelled) return;
if (res.data.success) {
const models = res.data.data?.models || [];
setAvailableModels(models);
setSelectedModel((prev) =>
prev && models.includes(prev) ? prev : models[0] || '',
);
} else {
setAvailableModels([]);
setSelectedModel('');
setModelsError(true);
}
} catch (e) {
if (cancelled) return;
setAvailableModels([]);
setSelectedModel('');
setModelsError(true);
}
if (!cancelled) {
setLoadingModels(false);
}
};
loadModels();
return () => {
cancelled = true;
};
}, [selectedToken?.id]);
const getModelConnectionPreset = (modelName = '') => {
const lower = modelName.toLowerCase();
if (lower.includes('claude')) {
return {
family: 'anthropic',
baseUrl: serverAddress,
openclawApi: 'anthropic-messages',
openclawProviderId: 'subrouter-anthropic',
opencodeProviderId: 'anthropic',
};
}
return {
family: 'openai',
baseUrl: `${serverAddress}/v1`,
openclawApi: 'openai-completions',
openclawProviderId: 'subrouter-openai',
opencodeProviderId: 'openai',
};
};
const getCCSwitchEndpoint = () => {
const app = CCSWITCH_APPS.find(
(item) => item.id === selectedCCSwitchApp,
);
const preset = getModelConnectionPreset(selectedModel);
if (app?.endpointType === 'anthropic') {
return serverAddress;
}
if (app?.id === 'openclaw' || app?.id === 'opencode') {
return preset.baseUrl;
}
return `${serverAddress}/v1`;
};
const generateCCSwitchLink = () => {
if (!selectedToken || !selectedModel) return '';
const params = new URLSearchParams({
resource: 'provider',
app: selectedCCSwitchApp,
name: site?.name || window.location.hostname,
homepage: serverAddress,
endpoint: getCCSwitchEndpoint(),
apiKey: `sk-${selectedToken.key}`,
model: selectedModel,
enabled: 'true',
notes: `${site?.name || window.location.hostname} · ${selectedModel}`,
});
return `ccswitch://v1/import?${params.toString()}`;
};
const generateConfig = () => {
if (!selectedToken || !selectedModel) return '';
const apiKey = `sk-${selectedToken.key}`;
switch (selectedTool) {
case 'claudecode':
return `{
"env": {
"ANTHROPIC_API_KEY": "${apiKey}",
"ANTHROPIC_BASE_URL": "${serverAddress}",
"ANTHROPIC_MODEL": "${selectedModel}"
}
}`;
case 'hermes':
return `#!/usr/bin/env bash
set -euo pipefail
# Hermes uses profiles for isolated config, API keys, memory, and sessions.
# This creates/updates a SubRouter profile and exports it as a tar.gz archive.
PROFILE_NAME="subrouter"
PROFILE_DIR="$HOME/.hermes/profiles/$PROFILE_NAME"
if ! hermes profile show "$PROFILE_NAME" >/dev/null 2>&1; then
hermes profile create "$PROFILE_NAME"
fi
mkdir -p "$PROFILE_DIR"
cat > "$PROFILE_DIR/config.yaml" <<'YAML'
model:
default: ${selectedModel}
provider: custom
base_url: ${serverAddress}/v1
api_key: ${apiKey}
YAML
hermes profile use "$PROFILE_NAME"
hermes profile export "$PROFILE_NAME" -o "./$PROFILE_NAME.tar.gz"
echo "Hermes profile exported to ./$PROFILE_NAME.tar.gz"`;
case 'ccswitch':
return generateCCSwitchLink();
case 'openclaw': {
const preset = getModelConnectionPreset(selectedModel);
const modelRef = `${preset.openclawProviderId}/${selectedModel}`;
return `{
"agents": {
"defaults": {
"models": {
"${modelRef}": {
"alias": "${selectedModel}"
}
},
"model": {
"primary": "${modelRef}"
}
}
},
"models": {
"mode": "merge",
"providers": {
"${preset.openclawProviderId}": {
"baseUrl": "${preset.baseUrl}",
"apiKey": "${apiKey}",
"api": "${preset.openclawApi}",
"models": [
{
"id": "${selectedModel}",
"name": "${selectedModel}"
}
]
}
}
}
}`;
}
case 'opencode': {
const preset = getModelConnectionPreset(selectedModel);
const modelRef = `${preset.opencodeProviderId}/${selectedModel}`;
return `{
"$schema": "https://opencode.ai/config.json",
"provider": {
"${preset.opencodeProviderId}": {
"options": {
"baseURL": "${preset.baseUrl}",
"apiKey": "${apiKey}"
},
"models": {
"${selectedModel}": {
"name": "${selectedModel}",
"options": {
"store": false
}
}
}
}
},
"model": "${modelRef}"
}`;
}
case 'cursor':
return `API Key: ${apiKey}
Base URL: ${serverAddress}/v1
Model: ${selectedModel}`;
case 'curl':
return `curl ${serverAddress}/v1/chat/completions \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer ${apiKey}" \\
-d '{
"model": "${selectedModel}",
"messages": [
{"role": "user", "content": "Hello!"}
]
}'`;
case 'python':
return `from openai import OpenAI
client = OpenAI(
api_key="${apiKey}",
base_url="${serverAddress}/v1"
)
response = client.chat.completions.create(
model="${selectedModel}",
messages=[
{"role": "user", "content": "Hello!"}
]
)
print(response.choices[0].message.content)`;
case 'anthropic':
return `import anthropic
client = anthropic.Anthropic(
api_key="${apiKey}",
base_url="${serverAddress}"
)
message = client.messages.create(
model="${selectedModel}",
max_tokens=1024,
messages=[
{"role": "user", "content": "Hello!"}
]
)
print(message.content[0].text)`;
default:
return '';
}
};
const getFilename = () => {
switch (selectedTool) {
case 'curl':
return 'api-call.sh';
case 'hermes':
return 'hermes-subrouter.sh';
case 'python':
case 'anthropic':
return 'main.py';
case 'cursor':
return 'cursor-config.txt';
default:
return (
selectedToolMeta.path.split('/').pop() || `${selectedToolMeta.id}.json`
);
}
};
const copyToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text);
} catch (e) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
};
const getSelectedToolBaseUrl = () => {
const preset = getModelConnectionPreset(selectedModel);
if (selectedTool === 'claudecode' || selectedTool === 'anthropic') {
return serverAddress;
}
if (selectedTool === 'openclaw' || selectedTool === 'opencode') {
return preset.baseUrl;
}
return `${serverAddress}/v1`;
};
const handleCopyValue = async (text, successKey = 'config.copied') => {
if (!text) return;
await copyToClipboard(text);
toast.success(t(successKey));
};
const handleCopy = async () => {
const config = generateConfig();
if (!config) return;
await copyToClipboard(config);
setCopied(true);
toast.success(t('config.copied'));
window.setTimeout(() => setCopied(false), 2000);
};
const handleCopyCCSwitchLink = async () => {
const deeplink = generateCCSwitchLink();
if (!deeplink) return;
await copyToClipboard(deeplink);
toast.success(t('config.importLinkCopied'));
};
const handleDownload = () => {
const config = generateConfig();
if (!config) return;
const blob = new Blob([config], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = getFilename();
a.click();
URL.revokeObjectURL(url);
toast.success(t('config.downloaded'));
};
const handleImportCCSwitch = () => {
const deeplink = generateCCSwitchLink();
if (!deeplink) return;
setShowCCSwitchDownload(false);
setLaunchingCCSwitch(true);
let dismissed = false;
let timerId = null;
const cleanup = () => {
window.removeEventListener('blur', handleBlur);
document.removeEventListener('visibilitychange', handleVisibilityChange);
if (timerId) {
window.clearTimeout(timerId);
}
};
const handleSuccess = () => {
if (dismissed) return;
dismissed = true;
cleanup();
setLaunchingCCSwitch(false);
};
const handleBlur = () => {
handleSuccess();
};
const handleVisibilityChange = () => {
if (document.hidden) {
handleSuccess();
}
};
window.addEventListener('blur', handleBlur);
document.addEventListener('visibilitychange', handleVisibilityChange);
timerId = window.setTimeout(() => {
if (dismissed) return;
cleanup();
setLaunchingCCSwitch(false);
setShowCCSwitchDownload(true);
}, 1500);
window.location.href = deeplink;
};
const config = generateConfig();
const ccSwitchLink = generateCCSwitchLink();
if (tokens.length === 0) {
return (
<div className="glass rounded-2xl p-6 text-center">
<svg
className="w-8 h-8 mx-auto mb-3 text-page-muted"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
/>
</svg>
<p className="text-sm text-page-secondary mb-1">
{t('config.noKeyPrompt')}
</p>
<p className="text-xs text-page-muted">{t('config.noKeyDesc')}</p>
</div>
);
}
return (
<>
<div className="glass rounded-2xl overflow-hidden">
<div className="px-5 py-4 border-b border-page-divider">
<h4 className="font-semibold text-sm text-page flex items-center gap-2">
<svg
className="w-4 h-4 text-brand-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
{t('config.title')}
</h4>
<p className="text-xs text-page-muted mt-1">{t('config.subtitle')}</p>
</div>
<div className="p-5 space-y-4">
<div>
<label className="flex items-center gap-2 text-xs font-medium text-page-label mb-2">
<svg
className="w-3 h-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
/>
</svg>
{t('config.selectKey')}
</label>
<ThemedSelect
value={selectedToken?.id ?? null}
onChange={setSelectedTokenId}
options={tokenOptions}
placeholder={t('config.selectKey')}
emptyLabel={t('config.noKeyDesc')}
/>
</div>
<div>
<label className="flex items-center gap-2 text-xs font-medium text-page-label mb-2">
<svg
className="w-3 h-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
/>
</svg>
{t('config.selectModel')}
</label>
<ThemedSelect
value={selectedModel}
onChange={setSelectedModel}
options={modelOptions}
placeholder={
loadingModels
? t('config.loadingModels')
: t('config.selectModel')
}
disabled={loadingModels || modelOptions.length === 0}
emptyLabel={
modelsError
? t('tokens.loadSupportedModelsFailed')
: t('tokens.noSupportedModels')
}
/>
</div>
<div className="rounded-xl border border-page-divider bg-page-surface/50 px-4 py-4 space-y-3">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div>
<p className="text-sm font-semibold text-page">
{t('config.apiUrlTitle')}
</p>
<p className="text-xs text-page-muted mt-1">
{t('config.apiUrlHint')}
</p>
</div>
<button
onClick={() =>
handleCopyValue(
getSelectedToolBaseUrl(),
'config.apiUrlCopied',
)
}
className="btn-secondary px-4 py-2"
>
{t('config.copyCurrentApiUrl')}
</button>
</div>
<div className="space-y-2">
<div className="rounded-lg bg-page-inset/60 px-3 py-2">
<div className="flex items-center justify-between gap-3 flex-wrap">
<span className="text-[11px] font-medium text-page-label">
{t('config.currentToolApiUrl')}
</span>
<code className="text-[11px] text-page-muted break-all">
{getSelectedToolBaseUrl()}
</code>
</div>
</div>
<div className="grid gap-2 md:grid-cols-2">
<button
onClick={() =>
handleCopyValue(
`${serverAddress}/v1`,
'config.apiUrlCopied',
)
}
className="rounded-lg border border-page-divider bg-page-inset/40 px-3 py-2 text-left hover:bg-page-surface-hover transition-colors"
>
<div className="text-[11px] font-medium text-page-label">
{t('config.openaiApiUrl')}
</div>
<code className="block mt-1 text-[11px] text-page-muted break-all">
{serverAddress}/v1
</code>
</button>
<button
onClick={() =>
handleCopyValue(
`${serverAddress}/`,
'config.apiUrlCopied',
)
}
className="rounded-lg border border-page-divider bg-page-inset/40 px-3 py-2 text-left hover:bg-page-surface-hover transition-colors"
>
<div className="text-[11px] font-medium text-page-label">
{t('config.anthropicApiUrl')}
</div>
<code className="block mt-1 text-[11px] text-page-muted break-all">
{serverAddress}/
</code>
</button>
</div>
</div>
</div>
<div>
<label className="text-xs font-medium text-page-label mb-2 block">
{t('config.selectTool')}
</label>
<div className="flex flex-wrap gap-2">
{TOOLS.map((tool) => (
<button
key={tool.id}
onClick={() => setSelectedTool(tool.id)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
selectedTool === tool.id
? 'bg-brand-500 text-white shadow-sm'
: 'surface text-page-secondary hover:bg-page-surface-hover'
}`}
>
{tool.name}
</button>
))}
</div>
</div>
<div className="rounded-xl border border-brand-500/20 bg-brand-500/5 px-4 py-4 space-y-4">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div>
<p className="text-sm font-semibold text-page">
{t('config.ccswitchTitle')}
</p>
<p className="text-xs text-page-muted mt-1">
{t('config.ccswitchHint')}
</p>
</div>
<span className="px-2.5 py-1 rounded-full text-[11px] font-medium bg-brand-500/15 text-brand-300">
CC Switch
</span>
</div>
<div>
<p className="text-xs font-medium text-page-label mb-2">
{t('config.selectCCSwitchApp')}
</p>
<div className="flex flex-wrap gap-2">
{CCSWITCH_APPS.map((app) => (
<button
key={app.id}
onClick={() => setSelectedCCSwitchApp(app.id)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
selectedCCSwitchApp === app.id
? 'bg-brand-500 text-white shadow-sm'
: 'bg-transparent border border-page-divider text-page-secondary hover:bg-page-surface-hover'
}`}
>
{app.name}
</button>
))}
</div>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={handleImportCCSwitch}
disabled={!ccSwitchLink || launchingCCSwitch}
className="btn-primary flex-1 min-w-[220px]"
title={t('config.importToCCSwitch')}
>
{launchingCCSwitch
? t('config.launchingCCSwitch')
: t('config.importToCCSwitch')}
</button>
<button
onClick={handleCopyCCSwitchLink}
disabled={!ccSwitchLink}
className="btn-secondary px-4 py-2.5"
title={t('config.copyImportLink')}
>
{t('config.copyImportLink')}
</button>
</div>
<div className="rounded-lg bg-page-inset/60 px-3 py-2">
<code className="text-[11px] leading-relaxed text-page-muted break-all">
{ccSwitchLink}
</code>
</div>
</div>
</div>
<div className="border-t border-page-divider">
<div className="flex items-center justify-between px-4 py-2.5 bg-page-inset/70">
<div className="flex items-center gap-2 min-w-0">
<div className="flex gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-red-400" />
<span className="w-2.5 h-2.5 rounded-full bg-yellow-400" />
<span className="w-2.5 h-2.5 rounded-full bg-green-400" />
</div>
<span className="text-xs text-page-muted ml-1 truncate">
{selectedToolMeta.path}
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleCopy}
disabled={!config}
className="p-1.5 rounded-md hover:bg-page-surface-hover transition-colors text-page-muted hover:text-page disabled:opacity-50 disabled:cursor-not-allowed"
title={t(
selectedTool === 'ccswitch'
? 'config.copyImportLink'
: 'config.copy',
)}
>
{copied ? (
<svg
className="w-3.5 h-3.5 text-green-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
) : (
<svg
className="w-3.5 h-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
)}
</button>
<button
onClick={handleDownload}
disabled={!config}
className="p-1.5 rounded-md hover:bg-page-surface-hover transition-colors text-page-muted hover:text-page disabled:opacity-50 disabled:cursor-not-allowed"
title={t('config.download')}
>
<svg
className="w-3.5 h-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</button>
</div>
</div>
<pre className="p-4 text-xs leading-relaxed overflow-x-auto max-h-72 font-mono text-page whitespace-pre-wrap break-all">
<code>{config || t('tokens.noSupportedModels')}</code>
</pre>
</div>
</div>
{showCCSwitchDownload && (
<div
className="modal-overlay fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onClick={() => setShowCCSwitchDownload(false)}
>
<div
className="glass rounded-2xl p-6 w-full max-w-md"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold text-page mb-2">
{t('config.ccswitchNotInstalledTitle')}
</h3>
<p className="text-sm text-page-secondary mb-5">
{t('config.ccswitchNotInstalledDesc')}
</p>
<div className="space-y-3">
<a
href={CCSWITCH_RELEASE_URL}
target="_blank"
rel="noreferrer"
className="btn-primary w-full text-center block"
>
{t('config.downloadCCSwitch')}
</a>
<a
href={CCSWITCH_REPO_URL}
target="_blank"
rel="noreferrer"
className="btn-secondary w-full text-center block"
>
{t('config.openCCSwitchRepo')}
</a>
</div>
<div className="flex justify-end mt-5">
<button
onClick={() => setShowCCSwitchDownload(false)}
className="btn-secondary"
>
{t('topup.close')}
</button>
</div>
</div>
</div>
)}
</>
);
};
export default ConfigExporter;
+77
View File
@@ -0,0 +1,77 @@
import React, { useMemo, useState } from 'react';
import { Bell, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useSite } from '../context/SiteContext';
export default function NotificationBell() {
const { t } = useTranslation();
const { site } = useSite();
const [open, setOpen] = useState(false);
const notifications = useMemo(() => {
if (!Array.isArray(site?.notifications)) return [];
return site.notifications
.filter((item) => item?.content)
.slice(0, 20);
}, [site?.notifications]);
if (notifications.length === 0) return null;
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="fixed bottom-6 right-6 z-[80] flex h-14 w-14 items-center justify-center rounded-full border border-brand-500/30 bg-brand-500 text-white shadow-2xl shadow-brand-500/30 transition hover:scale-105 hover:bg-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-400"
aria-label={t('notifications.open')}
>
<Bell size={22} />
<span className="absolute -right-1 -top-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-[11px] font-bold text-white ring-2 ring-[var(--page-bg)]">
{notifications.length > 9 ? '9+' : notifications.length}
</span>
</button>
{open && (
<div className="fixed inset-0 z-[90] flex items-end justify-center bg-black/55 px-4 py-6 backdrop-blur-sm sm:items-center" onClick={() => setOpen(false)}>
<div
className="w-full max-w-lg overflow-hidden rounded-3xl border border-page-card-border bg-[var(--page-bg)] shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-page-card-border bg-page-surface px-5 py-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-brand-500/15 text-page-link">
<Bell size={20} />
</div>
<div>
<h2 className="text-base font-semibold text-page">{t('notifications.title')}</h2>
<p className="text-xs text-page-muted">{t('notifications.subtitle')}</p>
</div>
</div>
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-full p-2 text-page-muted transition hover:bg-page-surface-hover hover:text-page"
aria-label={t('common.close')}
>
<X size={18} />
</button>
</div>
<div className="max-h-[65vh] space-y-3 overflow-y-auto p-5">
{notifications.map((item, index) => (
<div key={`${item.publish_date || 'notice'}-${index}`} className="rounded-2xl border border-page-card-border bg-page-surface p-4">
<div className="mb-2 inline-flex rounded-full bg-brand-500/10 px-3 py-1 text-xs font-medium text-page-link">
{item.publish_date || t('notifications.notice')}
</div>
<p className="whitespace-pre-wrap text-sm leading-6 text-page-secondary">
{item.content}
</p>
</div>
))}
</div>
</div>
</div>
)}
</>
);
}
+2 -2
View File
@@ -9,9 +9,9 @@ export function AuthProvider({ children }) {
const [loading, setLoading] = useState(true);
// Refresh user data — used after login, redeem, subscribe, etc.
const refreshUser = useCallback(async () => {
const refreshUser = useCallback(async (config = {}) => {
try {
const res = await getUserSelf();
const res = await getUserSelf(config);
if (res.data.success) {
setUser(res.data.data);
return res.data.data;
+9 -1
View File
@@ -72,6 +72,7 @@ export function useCurrency() {
const symbol = currency?.symbol || '¥';
const rate = currency?.exchange_rate || 7;
const code = currency?.code || 'CNY';
const usdRate = currency?.usd_exchange_rate || 7;
const fmt = (usdValue, decimals = 4) => {
if (usdValue == null || isNaN(usdValue)) return '-';
@@ -79,5 +80,12 @@ export function useCurrency() {
return symbol + converted.toFixed(decimals);
};
return { symbol, rate, code, fmt };
const fmtCNY = (cnyValue, decimals = 2) => {
if (cnyValue == null || isNaN(cnyValue)) return '-';
const v = Number(cnyValue);
const converted = code === 'CNY' ? v : v / usdRate;
return symbol + converted.toFixed(decimals);
};
return { symbol, rate, code, fmt, fmtCNY, usdRate };
}
+131 -38
View File
@@ -9,6 +9,7 @@
"nav.signUp": "Sign Up",
"nav.logout": "Logout",
"nav.contact": "Contact",
"common.close": "Close",
"home.heroSubtitle": "Access the world's most powerful AI models through a single API endpoint. Simple, affordable, reliable.",
"home.getStarted": "Get Started Free",
@@ -37,6 +38,13 @@
"home.createFreeAccount": "Create Free Account",
"home.signIn": "Sign In",
"home.defaultHeroTitle": "AI API Platform",
"home.apiEndpointsTitle": "API Endpoints",
"home.apiEndpointsDesc": "After creating an API key, use any of these URLs. Pick the endpoint closest to your network.",
"home.apiEndpointClickToCopy": "Click an address to copy it",
"home.apiEndpointSite": "Official Domain",
"home.apiEndpointWorld": "Overseas calls",
"home.apiEndpointChina": "China calls",
"home.apiEndpointApiOnly": "API only",
"login.welcomeBack": "Welcome Back",
"login.signInTo": "Sign in to {{name}}",
@@ -84,6 +92,10 @@
"dashboard.redeem": "Redeem",
"dashboard.redeemSuccess": "Code redeemed successfully!",
"dashboard.quickLinks": "Quick Links",
"notifications.open": "Open announcements",
"notifications.title": "Announcements",
"notifications.subtitle": "Latest updates from the site owner",
"notifications.notice": "Notice",
"dashboard.apiKeys": "API Keys",
"dashboard.manageKeys": "Manage your keys",
"dashboard.packages": "Packages",
@@ -93,15 +105,15 @@
"dashboard.apiDocs": "API Docs",
"dashboard.comingSoon": "Coming soon",
"subDist.nav": "Launch Sub-site",
"subDist.dashboardEntry": "Pay once and manage your own sub-site",
"subDist.badge": "Site Owner Plan",
"subDist.title": "Launch Your Own Sub-site",
"subDist.subtitle": "Pay through {{name}} to open your own sub-site. After payment, the system will automatically grant owner access and continue the management flow.",
"subDist.nav": "Launch Site",
"subDist.dashboardEntry": "Pay once and manage your own branded site",
"subDist.badge": "Brand Plan",
"subDist.title": "Launch Your Own Site",
"subDist.subtitle": "Quickly launch and configure your own branded site through {{name}}. After payment, management access will be enabled automatically and the setup flow will continue.",
"subDist.defaultSiteName": "this site",
"subDist.notAvailable": "Sub-site launch is currently unavailable",
"subDist.disabledFallback": "Please contact the site owner to enable this feature",
"subDist.loginHint": "Please log in or register on this site first. After successful payment, your account will be upgraded to a site owner.",
"subDist.notAvailable": "Site launch is currently unavailable",
"subDist.disabledFallback": "Please contact the site administrator to enable this feature",
"subDist.loginHint": "Please log in or register on this site first. After successful payment, your account will receive management access automatically.",
"subDist.goLogin": "Log In",
"subDist.goRegister": "Register",
"subDist.siteName": "Site Name",
@@ -111,25 +123,34 @@
"subDist.paymentMethod": "Payment Method",
"subDist.chain": "Blockchain",
"subDist.token": "Token",
"subDist.payAndOpen": "Pay ¥{{price}} to Launch",
"subDist.payAndOpen": "Launch Now {{symbol}}{{price}}",
"subDist.currentUserHint": "Current paying account: {{user}}, payment method: {{method}}",
"subDist.postPayHint": "After payment, your owner access will be activated automatically. If a login is required, continue with the same account.",
"subDist.postPayHint": "After payment, your management access will be activated automatically. If login is required, continue with the same account to finish setup.",
"subDist.cryptoTitle": "Crypto Order Created",
"subDist.cryptoHint": "Please transfer using the details below. Your sub-site will be activated automatically after confirmation.",
"subDist.cryptoHint": "Please transfer using the details below. Your site will be activated automatically after confirmation and setup will begin.",
"subDist.wallet": "Wallet",
"subDist.amount": "Amount",
"subDist.tradeNo": "Trade No",
"subDist.priceCardTitle": "Pricing",
"subDist.openPrice": "Launch Price",
"subDist.priceSummary": "This page only shows the final price you need to pay. Internal supply cost, upstream profit, and settlement details are only shown after activation in the appropriate back-office flow.",
"subDist.ruleTitle": "Profit Rules",
"subDist.rule1": "You are paying the launch price configured by the current site owner, and activation will follow the current site's rules after payment succeeds.",
"subDist.rule2": "Upstream profit, discount spread, and internal settlement belong to the owner back-office flow and are not disclosed on this public page.",
"subDist.rule3": "Once activated, your account is upgraded to a site owner and you can continue configuring domains, themes, pricing, and models.",
"subDist.loginRequired": "Please log in before launching a sub-site",
"subDist.priceSummary": "This page only shows the final launch price. After payment succeeds, you can continue directly into site setup and configuration.",
"subDist.ruleTitle": "Launch Details",
"subDist.rule1": "You are paying the launch price shown on this page, and activation will be completed automatically after payment succeeds.",
"subDist.rule2": "Once activated, you can continue configuring domains, themes, pricing, models, and other site settings.",
"subDist.rule3": "If login is required later, continue with the same account to enter the management console.",
"subDist.loginRequired": "Please log in before launching your site",
"subDist.fillRequired": "Please fill in the site name, slug, and payment method",
"subDist.selectPayment": "Please select a payment method",
"subDist.createFailed": "Failed to create order",
"subDist.paymentPageOpened": "Order created. Please complete payment on the opened payment page.",
"subDist.paymentPageFailed": "Failed to open the payment page. Please try again later or contact the site administrator.",
"subDist.popupBlocked": "The browser blocked the payment window. Please allow pop-ups and try again.",
"subDist.cryptoOrderCreated": "Crypto order created. Please complete the transfer using the details on this page.",
"subDist.confirmingPayment": "Confirming payment result...",
"subDist.paymentPending": "Returned to this page. If you have completed payment, activation will finish after the callback is confirmed. Please refresh shortly.",
"subDist.openedSuccess": "Site activated successfully",
"subDist.alreadyOpenTitle": "Your site is already activated",
"subDist.alreadyOpenDesc": "Site {{name}} has been activated. Use the same account in the main console to continue setup.",
"tokens.title": "API Keys",
"tokens.subtitle": "Manage your API access tokens",
@@ -168,9 +189,33 @@
"tokens.unavailable": "Unavailable",
"tokens.rmbPerUsd": "RMB / $1",
"tokens.myKeys": "My API Keys",
"tokens.viewSupportedModels": "Supported Models",
"tokens.hideSupportedModels": "Hide Models",
"tokens.loadingSupportedModels": "Loading supported models...",
"tokens.loadSupportedModelsFailed": "Failed to load supported models",
"tokens.supportedModels": "Supported Models",
"tokens.supportedProviders": "Providers",
"tokens.noSupportedModels": "No available models for this key",
"tokens.restrictedByModels": "Model limited",
"tokens.restrictedByProviders": "Provider limited",
"tokens.selectedGroup": "Selected Group",
"tokens.quickStart": "Quick Start",
"tokens.quickStartComment": "# Use your API key with OpenAI-compatible endpoint",
"tokens.viewGroupPricing": "View Reference Pricing",
"tokens.groupPricingTitle": "Reference Pricing",
"tokens.groupPricingSubtitle": "Shows anonymous reference prices and price ranges for this group without exposing provider names.",
"tokens.groupPricingNotice": "Actual billing dynamically selects from currently available routes in this group. Prices below are for reference only.",
"tokens.groupPricingSearchPlaceholder": "Search models in this group...",
"tokens.groupPricingLoading": "Loading group reference pricing...",
"tokens.groupPricingNoData": "No reference pricing is available for this group yet.",
"tokens.groupPricingNoMatch": "No models match your search.",
"tokens.groupPricingReferencePrice": "Reference input / call price",
"tokens.groupPricingLines": "Routes",
"tokens.groupPricingLineUnitSingle": "route",
"tokens.groupPricingLineUnit": "routes",
"tokens.groupPricingSamePrice": "same price",
"tokens.groupPricingAvailableLines": "Available routes",
"tokens.groupPricingAvailableModels": "Available models",
"config.title": "Config File Export",
"config.subtitle": "Select your API Key and model to generate config files",
@@ -188,6 +233,25 @@
"config.noKeyDesc": "Config files will be generated once you have a key",
"config.searchModel": "Search models...",
"config.noModelMatch": "No models match your search",
"config.loadingModels": "Loading models...",
"config.apiUrlTitle": "API Endpoints",
"config.apiUrlHint": "After creating an API key, you can connect directly with the Base URLs below. Click any address to copy it.",
"config.copyCurrentApiUrl": "Copy current tool URL",
"config.currentToolApiUrl": "Recommended Base URL for current tool",
"config.openaiApiUrl": "OpenAI-compatible Base URL",
"config.anthropicApiUrl": "Anthropic / Claude Base URL",
"config.apiUrlCopied": "API URL copied",
"config.ccswitchTitle": "CC Switch Quick Import",
"config.selectCCSwitchApp": "Choose target app",
"config.ccswitchHint": "Pick the destination app and import into CC Switch directly without switching the export tool first.",
"config.importToCCSwitch": "Import to CC Switch",
"config.launchingCCSwitch": "Launching CC Switch...",
"config.copyImportLink": "Copy import link",
"config.importLinkCopied": "CC Switch import link copied",
"config.ccswitchNotInstalledTitle": "CC Switch Not Detected",
"config.ccswitchNotInstalledDesc": "If CC Switch did not open automatically, it may not be installed on this device yet. Install it first, then try the quick import again.",
"config.downloadCCSwitch": "Download CC Switch",
"config.openCCSwitchRepo": "Open project page",
"pricing.title": "Model Pricing",
"pricing.subtitle": "Transparent, pay-as-you-go pricing for all available models. Prices shown per 1M tokens.",
@@ -197,6 +261,10 @@
"pricing.model": "Model",
"pricing.inputPrice": "Input / 1M tokens",
"pricing.outputPrice": "Output / 1M tokens",
"pricing.cacheReadPrice": "Cache read / 1M tokens",
"pricing.cacheCreationPrice": "Cache creation / 1M tokens",
"pricing.cacheCreation5m": "5m",
"pricing.cacheCreation1h": "1h",
"pricing.perCall": "Per call",
"pricing.perCallUnit": "call",
"pricing.status": "Status",
@@ -204,13 +272,19 @@
"pricing.unknown": "Unknown",
"pricing.vendor": "Vendor",
"pricing.allVendors": "All",
"供应商": "Provider",
"供应商介绍": "Provider introduction",
"供应商定价": "Provider pricing",
"定价方式": "Pricing",
"按次计费": "Per call",
"按量计费": "Pay as you go",
"packages.title": "Packages & Plans",
"packages.subtitle": "Choose a package that fits your usage. All plans include access to all available models.",
"packages.noPackages": "No packages available at the moment.",
"packages.checkPricing": "Check model pricing",
"packages.daysAccess": "{{count}} days access",
"packages.creditIncluded": "¥{{amount}} credit included",
"packages.creditIncluded": "{{symbol}}{{amount}} credit included",
"packages.requestsMin": "{{count}} requests/min",
"packages.allModels": "All models access",
"packages.openaiApi": "OpenAI-compatible API",
@@ -218,7 +292,7 @@
"packages.signUpToSubscribe": "Sign Up to Subscribe",
"packages.processing": "Processing...",
"packages.confirmTitle": "Confirm Subscription",
"packages.confirmDesc": "Subscribe to {{name}} for ${{price}}?",
"packages.confirmDesc": "Subscribe to {{name}} for {{price}}?",
"packages.yourBalance": "Your balance:",
"packages.insufficientBalance": "Insufficient balance. Please top up before subscribing.",
"packages.confirm": "Confirm",
@@ -234,8 +308,8 @@
"packages.resetDaily": "Resets daily",
"packages.resetWeekly": "Resets weekly",
"packages.resetMonthly": "Resets monthly",
"packages.periodicQuota": "¥{{amount}} {{period}} quota",
"packages.subscriptionInfo": "This is a subscription ({{period}}), valid for {{days}} days. Each period grants ¥{{amount}} quota. Unused quota expires.",
"packages.periodicQuota": "{{symbol}}{{amount}} {{period}} quota",
"packages.subscriptionInfo": "This is a subscription ({{period}}), valid for {{days}} days. Each period grants {{symbol}}{{amount}} quota. Unused quota expires.",
"packages.officialEquiv": "Equivalent to ${{amount}} at {{model}} official pricing",
"common.sessionExpired": "Session expired, please log in again",
@@ -279,6 +353,11 @@
"topup.close": "Close",
"topup.selectChain": "Please select a blockchain network",
"topup.selectToken": "Please select a token",
"topup.cryptoSelectionHint": "Choose the blockchain and token first, then click the button below to generate a payment address.",
"topup.cryptoStepChain": "1. Select network",
"topup.cryptoStepToken": "2. Select token",
"topup.cryptoSelectedSummary": "Current payment method",
"topup.generateCryptoAddress": "3. Generate {{method}} payment address",
"topup.token": "Token",
"topup.payWith": "Pay with",
"topup.payNow": "Pay",
@@ -286,9 +365,9 @@
"topup.creemPayment": "Creem Payment",
"topup.inviteTitle": "Referral Program",
"topup.inviteSubtitle": "Invite friends and earn ongoing commissions",
"topup.affAvailable": "Available Commission",
"topup.affTotal": "Total Commission",
"topup.inviteSubtitle": "Invite friends and earn ongoing rewards",
"topup.affAvailable": "Available Rewards",
"topup.affTotal": "Total Rewards",
"topup.affCount": "Invited Users",
"topup.inviteLink": "Invitation Link",
"topup.copy": "Copy",
@@ -315,31 +394,31 @@
"topup.invalidWithdrawAmount": "Please enter a valid withdrawal amount",
"topup.withdrawExceedsBalance": "Withdrawal amount cannot exceed your withdrawable balance",
"topup.enterWithdrawMethod": "Please enter a payment method",
"topup.currentCommissionRateLabel": "Commission Rate",
"topup.currentCommissionRateDesc": "All regular users get a default {{rate}}% commission. It is paid only from the sub-site owner's profit and never touches the main site's base cost.",
"topup.kolApplyTitle": "Apply for a Higher Commission Rate",
"topup.kolApplyDesc": "Your default commission is {{rate}}%. If you have promotion resources, you can apply for a higher custom rate from the sub-site owner.",
"topup.currentCommissionRateLabel": "Current Reward Rate",
"topup.currentCommissionRateDesc": "All regular users get a default {{rate}}% referral reward. Payout and settlement follow the current promotion rules.",
"topup.kolApplyTitle": "Apply for a Higher Reward Rate",
"topup.kolApplyDesc": "Your default reward rate is {{rate}}%. If you have promotion resources, you can submit your profile to apply for a higher partner rate.",
"topup.kolApplyAction": "Apply Now",
"topup.kolPendingTitle": "Commission Upgrade Under Review",
"topup.kolPendingDesc": "Your request has been submitted. The sub-site owner will review it and set a new rate if approved.",
"topup.kolPendingTitle": "Reward Review in Progress",
"topup.kolPendingDesc": "Your request has been submitted. If approved, your reward rate will be updated.",
"topup.kolPendingBadge": "Pending",
"topup.kolApprovedTitle": "Custom Commission Enabled",
"topup.kolApprovedDesc": "You are currently using a custom commission rate approved by the sub-site owner.",
"topup.kolRemarkLabel": "Owner note: ",
"topup.kolApprovedTitle": "Custom Reward Enabled",
"topup.kolApprovedDesc": "You are currently using a custom reward rate approved through review.",
"topup.kolRemarkLabel": "Review note: ",
"topup.kolRejectedLabel": "Request not approved: ",
"topup.kolRejectedFallback": "The request was not approved yet. You can improve your materials and apply again.",
"topup.kolApplyModalTitle": "Apply for a Higher Commission Rate",
"topup.kolApplyModalDesc": "Submit your promotion profile and the sub-site owner can approve a higher rate for you.",
"topup.kolApplyModalTitle": "Apply for a Higher Reward Rate",
"topup.kolApplyModalDesc": "Submit your promotion profile and your reward rate will be updated after approval.",
"topup.kolSocialLabel": "Social Link",
"topup.kolSocialPlaceholder": "Enter your profile, channel, or account link",
"topup.kolFollowersLabel": "Followers / Community Size",
"topup.kolFollowersPlaceholder": "For example: 5,000 followers / 3 groups",
"topup.kolPlanLabel": "Promotion Plan",
"topup.kolPlanPlaceholder": "Tell us how you plan to promote this sub-site",
"topup.kolPlanPlaceholder": "Tell us how you plan to promote this site",
"topup.kolContactLabel": "Contact Info",
"topup.kolContactPlaceholder": "Enter WeChat, Telegram, email, etc.",
"topup.kolApplySubmit": "Submit Request",
"topup.kolApplySuccess": "Application submitted. Please wait for the owner review.",
"topup.kolApplySuccess": "Application submitted. Please wait for review.",
"topup.kolSocialRequired": "Please enter a social link",
"dashboard.topupDesc": "Recharge account balance",
@@ -367,11 +446,25 @@
"logs.next": "Next",
"缓存命中 Tokens": "Cache Hit Tokens",
"缓存创建 Tokens": "Cache Creation Tokens",
"缓存创建 Tokens (5m)": "Cache Creation Tokens (5m)",
"缓存创建 Tokens (1h)": "Cache Creation Tokens (1h)",
"流式": "Streaming",
"是": "Yes",
"否": "No",
"首字时间": "First Token Time",
"实际扣费来源": "Actual Billing Source",
"钱包": "Wallet",
"订阅": "Subscription",
"分站套餐": "Distributor Package",
"主站订阅": "Main-Site Subscription",
"后台订阅": "Admin Subscription",
"计费方式": "Billing Method",
"订阅抵扣": "Subscription Deduction"
"订阅抵扣": "Subscription Deduction",
"定价方式": "Pricing",
"价格": "Price",
"输入价格": "Input Price",
"输出价格": "Output Price",
"缓存读取价格": "Cache Read Price",
"缓存创建价格": "Cache Creation Price"
}
}
+135 -42
View File
@@ -9,6 +9,7 @@
"nav.signUp": "注册",
"nav.logout": "退出",
"nav.contact": "联系我们",
"common.close": "关闭",
"home.heroSubtitle": "通过单一 API 端点访问全球最强大的 AI 模型。简单、实惠、可靠。",
"home.getStarted": "免费开始",
@@ -37,6 +38,13 @@
"home.createFreeAccount": "免费创建账户",
"home.signIn": "登录",
"home.defaultHeroTitle": "AI API 平台",
"home.apiEndpointsTitle": "API 调用地址",
"home.apiEndpointsDesc": "创建 API Key 后,可使用以下任一地址调用。优先选择最适合当前网络的位置。",
"home.apiEndpointClickToCopy": "点击地址即可复制",
"home.apiEndpointSite": "官网域名",
"home.apiEndpointWorld": "海外调用",
"home.apiEndpointChina": "中国调用",
"home.apiEndpointApiOnly": "仅支持 API",
"login.welcomeBack": "欢迎回来",
"login.signInTo": "登录到 {{name}}",
@@ -84,6 +92,10 @@
"dashboard.redeem": "兑换",
"dashboard.redeemSuccess": "兑换码兑换成功!",
"dashboard.quickLinks": "快捷入口",
"notifications.open": "打开公告通知",
"notifications.title": "公告通知",
"notifications.subtitle": "站点管理员发布的最新消息",
"notifications.notice": "通知",
"dashboard.apiKeys": "API 密钥",
"dashboard.manageKeys": "管理您的密钥",
"dashboard.packages": "套餐",
@@ -93,43 +105,52 @@
"dashboard.apiDocs": "API 文档",
"dashboard.comingSoon": "即将推出",
"subDist.nav": "开通分站",
"subDist.dashboardEntry": "付费开通并管理自己的分站",
"subDist.badge": "站长计划",
"subDist.title": "开通你的下级分站",
"subDist.subtitle": "基于 {{name}} 直接付费开通自己的分站。支付完成后,系统会自动为您开通站长权限并进入管理流程。",
"subDist.defaultSiteName": "当前分站",
"subDist.notAvailable": "当前暂未开放下级分站",
"subDist.disabledFallback": "请联系站长开启该功能",
"subDist.loginHint": "请先登录或注册当前分站账户,支付成功后会自动获得分站站长权限。",
"subDist.nav": "开通站点",
"subDist.dashboardEntry": "付费开通并管理自己的专属站点",
"subDist.badge": "品牌计划",
"subDist.title": "开通你的专属站点",
"subDist.subtitle": "基于 {{name}} 快速开通并配置自己的专属站点。支付完成后,系统会自动为您开通管理权限并进入初始化流程。",
"subDist.defaultSiteName": "当前站点",
"subDist.notAvailable": "当前暂未开放站点开通",
"subDist.disabledFallback": "请联系站点管理员开启该功能",
"subDist.loginHint": "请先登录或注册当前站点账户,支付成功后会自动获得管理权限。",
"subDist.goLogin": "去登录",
"subDist.goRegister": "去注册",
"subDist.siteName": "站点名称",
"subDist.siteNamePlaceholder": "例如:我的 AI 分站",
"subDist.siteNamePlaceholder": "例如:我的 AI 站点",
"subDist.siteSlug": "站点标识",
"subDist.siteSlugHelp": "将作为您的专属分站标识,支付成功后不可修改。",
"subDist.siteSlugHelp": "将作为您的专属站点标识,支付成功后不可修改。",
"subDist.paymentMethod": "支付方式",
"subDist.chain": "区块链",
"subDist.token": "代币",
"subDist.payAndOpen": "支付开通 ¥{{price}}",
"subDist.payAndOpen": "立即开通 {{symbol}}{{price}}",
"subDist.currentUserHint": "当前支付账号:{{user}},支付方式:{{method}}",
"subDist.postPayHint": "支付完成后系统会自动为您开通分站站长权限;如需补登录,请使用同一账号继续进入站长后台。",
"subDist.postPayHint": "支付完成后系统会自动为您开通管理权限;如需补登录,请使用同一账号继续完成初始化。",
"subDist.cryptoTitle": "加密货币订单已创建",
"subDist.cryptoHint": "请按照以下信息转账,到账后系统会自动开通分站。",
"subDist.cryptoHint": "请按照以下信息转账,到账后系统会自动开通并开始初始化站点。",
"subDist.wallet": "收款地址",
"subDist.amount": "支付金额",
"subDist.tradeNo": "订单号",
"subDist.priceCardTitle": "费用说明",
"subDist.openPrice": "当前开通价",
"subDist.priceSummary": "当前页面仅展示您的实际开通价格。供货成本、上级收益与内部结算明细将在开通完成后进入对应后台流程。",
"subDist.ruleTitle": "收益规则",
"subDist.rule1": "您支付的是当前分站站长设置的开通价,支付成功后会按当前站点规则自动完成开通。",
"subDist.rule2": "上级站长收益、折扣差与内部结算属于站长后台结算逻辑,不在当前公开页面展示。",
"subDist.rule3": "开通成功后,您会升级为分站站长,并可继续配置域名、主题、价格和模型。",
"subDist.loginRequired": "请先登录后再开通分站",
"subDist.priceSummary": "当前页面仅展示您的实际开通价格。支付成功后,您即可进入站点初始化与配置流程。",
"subDist.ruleTitle": "开通说明",
"subDist.rule1": "您支付的是当前页面展示的开通价格,支付成功后系统会自动完成开通。",
"subDist.rule2": "开通成功后,您可以继续配置域名、主题、价格和模型等站点信息。",
"subDist.rule3": "如需补登录或继续设置,请使用同一账号进入管理后台。",
"subDist.loginRequired": "请先登录后再开通站点",
"subDist.fillRequired": "请填写站点名称、标识和支付方式",
"subDist.selectPayment": "请选择支付方式",
"subDist.createFailed": "创建订单失败",
"subDist.paymentPageOpened": "订单已创建,请在打开的支付页面完成付款",
"subDist.paymentPageFailed": "支付页面打开失败,请稍后重试或联系站点管理员",
"subDist.popupBlocked": "浏览器拦截了支付窗口,请允许弹窗后重试",
"subDist.cryptoOrderCreated": "加密货币订单已创建,请按页面信息完成转账",
"subDist.confirmingPayment": "正在确认支付结果...",
"subDist.paymentPending": "已返回当前页面。若您已完成付款,系统会在回调确认后自动开通,请稍后刷新查看。",
"subDist.openedSuccess": "站点已开通成功",
"subDist.alreadyOpenTitle": "您已开通站点",
"subDist.alreadyOpenDesc": "站点 {{name}} 已完成开通,请使用同一账号进入主站控制台继续配置。",
"tokens.title": "API 密钥",
"tokens.subtitle": "管理您的 API 访问令牌",
@@ -168,9 +189,33 @@
"tokens.unavailable": "暂不可用",
"tokens.rmbPerUsd": "块 / 1 美元",
"tokens.myKeys": "我的 API Keys",
"tokens.viewSupportedModels": "支持模型",
"tokens.hideSupportedModels": "收起模型",
"tokens.loadingSupportedModels": "正在加载支持模型...",
"tokens.loadSupportedModelsFailed": "加载支持模型失败",
"tokens.supportedModels": "支持模型",
"tokens.supportedProviders": "支持商家",
"tokens.noSupportedModels": "当前没有可用模型",
"tokens.restrictedByModels": "模型限制",
"tokens.restrictedByProviders": "商家限制",
"tokens.selectedGroup": "已选分组",
"tokens.quickStart": "快速开始",
"tokens.quickStartComment": "# 使用 OpenAI 兼容端点调用 API",
"tokens.viewGroupPricing": "查看参考价",
"tokens.groupPricingTitle": "分组参考价",
"tokens.groupPricingSubtitle": "仅展示该分组下的匿名参考价与价格区间,不展示商家名称。",
"tokens.groupPricingNotice": "实际计费会在该分组当前可用线路中动态选择,以下价格仅作为参考。",
"tokens.groupPricingSearchPlaceholder": "搜索分组内模型...",
"tokens.groupPricingLoading": "正在加载分组参考价...",
"tokens.groupPricingNoData": "当前分组暂无可展示的参考价。",
"tokens.groupPricingNoMatch": "没有匹配的模型。",
"tokens.groupPricingReferencePrice": "参考输入价 / 按次价",
"tokens.groupPricingLines": "线路",
"tokens.groupPricingLineUnitSingle": "条线路",
"tokens.groupPricingLineUnit": "条线路",
"tokens.groupPricingSamePrice": "同价",
"tokens.groupPricingAvailableLines": "可用线路",
"tokens.groupPricingAvailableModels": "可用模型",
"config.title": "配置文件导出",
"config.subtitle": "选择您的 API Key 和模型,一键生成配置文件",
@@ -188,6 +233,25 @@
"config.noKeyDesc": "创建密钥后即可自动生成配置文件",
"config.searchModel": "搜索模型...",
"config.noModelMatch": "没有匹配的模型",
"config.loadingModels": "正在加载模型...",
"config.apiUrlTitle": "API 接入地址",
"config.apiUrlHint": "创建好 API Key 后,可直接使用下面的 Base URL 接入。点击地址即可复制。",
"config.copyCurrentApiUrl": "复制当前工具地址",
"config.currentToolApiUrl": "当前工具推荐 Base URL",
"config.openaiApiUrl": "OpenAI 兼容 Base URL",
"config.anthropicApiUrl": "Anthropic / Claude Base URL",
"config.apiUrlCopied": "API 地址已复制",
"config.ccswitchTitle": "CC Switch 一键导入",
"config.selectCCSwitchApp": "选择导入目标",
"config.ccswitchHint": "不用切换工具,直接选择目标应用后即可一键导入到 CC Switch。",
"config.importToCCSwitch": "一键导入到 CC Switch",
"config.launchingCCSwitch": "正在唤起 CC Switch...",
"config.copyImportLink": "复制导入链接",
"config.importLinkCopied": "CC Switch 导入链接已复制",
"config.ccswitchNotInstalledTitle": "未检测到 CC Switch",
"config.ccswitchNotInstalledDesc": "如果没有自动唤起 CC Switch,说明本机可能还未安装。可以先下载安装,再回来继续一键导入。",
"config.downloadCCSwitch": "下载 CC Switch",
"config.openCCSwitchRepo": "打开项目主页",
"pricing.title": "模型定价",
"pricing.subtitle": "透明的按量付费定价。价格以每百万 token 计。",
@@ -197,6 +261,10 @@
"pricing.model": "模型",
"pricing.inputPrice": "输入 / 百万 token",
"pricing.outputPrice": "输出 / 百万 token",
"pricing.cacheReadPrice": "缓存读取 / 百万 token",
"pricing.cacheCreationPrice": "缓存创建 / 百万 token",
"pricing.cacheCreation5m": "5m",
"pricing.cacheCreation1h": "1h",
"pricing.perCall": "按次计费",
"pricing.perCallUnit": "次",
"pricing.status": "状态",
@@ -204,13 +272,19 @@
"pricing.unknown": "未知",
"pricing.vendor": "厂商",
"pricing.allVendors": "全部",
"供应商": "供应商",
"供应商介绍": "供应商介绍",
"供应商定价": "供应商定价",
"定价方式": "定价方式",
"按次计费": "按次计费",
"按量计费": "按量计费",
"packages.title": "套餐方案",
"packages.subtitle": "选择适合您用量的套餐。所有方案均可访问全部可用模型。",
"packages.noPackages": "暂无可用套餐。",
"packages.checkPricing": "查看模型定价",
"packages.daysAccess": "{{count}} 天有效期",
"packages.creditIncluded": "含 ¥{{amount}} 额度",
"packages.creditIncluded": "含 {{symbol}}{{amount}} 额度",
"packages.requestsMin": "{{count}} 次请求/分钟",
"packages.allModels": "全部模型可用",
"packages.openaiApi": "OpenAI 兼容 API",
@@ -218,7 +292,7 @@
"packages.signUpToSubscribe": "注册后订阅",
"packages.processing": "处理中...",
"packages.confirmTitle": "确认订阅",
"packages.confirmDesc": "订阅 {{name}},价格 ${{price}}?",
"packages.confirmDesc": "订阅 {{name}},价格 {{price}}?",
"packages.yourBalance": "您的余额:",
"packages.insufficientBalance": "余额不足,请先充值。",
"packages.confirm": "确认",
@@ -234,8 +308,8 @@
"packages.resetDaily": "每日重置",
"packages.resetWeekly": "每周重置",
"packages.resetMonthly": "每月重置",
"packages.periodicQuota": "{{period}}额度 ¥{{amount}}",
"packages.subscriptionInfo": "📋 此套餐为周期订阅({{period}}),有效期 {{days}} 天。每个周期获得 ¥{{amount}} 额度,未用完作废。",
"packages.periodicQuota": "{{period}}额度 {{symbol}}{{amount}}",
"packages.subscriptionInfo": "📋 此套餐为周期订阅({{period}}),有效期 {{days}} 天。每个周期获得 {{symbol}}{{amount}} 额度,未用完作废。",
"packages.officialEquiv": "相当于 {{model}} 官方 ${{amount}} 额度",
"common.sessionExpired": "会话已过期,请重新登录",
@@ -279,16 +353,21 @@
"topup.close": "关闭",
"topup.selectChain": "请选择区块链网络",
"topup.selectToken": "请选择代币",
"topup.cryptoSelectionHint": "先选择区块链网络和代币,再点击下方按钮生成支付地址。",
"topup.cryptoStepChain": "1. 选择网络",
"topup.cryptoStepToken": "2. 选择代币",
"topup.cryptoSelectedSummary": "当前支付方式",
"topup.generateCryptoAddress": "3. 生成 {{method}} 支付地址",
"topup.token": "代币",
"topup.payWith": "使用",
"topup.payNow": "支付",
"topup.quotaIncluded": "含 {{count}} 额度",
"topup.creemPayment": "Creem 支付",
"topup.inviteTitle": "邀请返佣",
"topup.inviteSubtitle": "邀请好友注册,持续获得消费佣金",
"topup.affAvailable": "可用佣金",
"topup.affTotal": "累计佣金",
"topup.inviteTitle": "邀请奖励",
"topup.inviteSubtitle": "邀请好友注册,持续获得推广奖励",
"topup.affAvailable": "可用奖励",
"topup.affTotal": "累计奖励",
"topup.affCount": "邀请人数",
"topup.inviteLink": "邀请链接",
"topup.copy": "复制",
@@ -315,31 +394,31 @@
"topup.invalidWithdrawAmount": "请输入有效的提现金额",
"topup.withdrawExceedsBalance": "提现金额不能超过可提现余额",
"topup.enterWithdrawMethod": "请输入收款方式",
"topup.currentCommissionRateLabel": "当前返佣比例",
"topup.currentCommissionRateDesc": "普通用户默认可获得 {{rate}}% 返佣,返佣只从分站站长利润中结算,不会扣到主站进货成本。",
"topup.kolApplyTitle": "申请更高返佣比例",
"topup.kolApplyDesc": "当前默认返佣为 {{rate}}%,如果您有推广资源,可以向分站站长申请更高返佣。",
"topup.currentCommissionRateLabel": "当前奖励比例",
"topup.currentCommissionRateDesc": "普通用户默认可获得 {{rate}}% 推广奖励,具体发放与结算以活动规则为准。",
"topup.kolApplyTitle": "申请更高奖励比例",
"topup.kolApplyDesc": "当前默认奖励比例为 {{rate}}%。如果您有推广资源,可以提交资料申请更高合作比例。",
"topup.kolApplyAction": "申请提额",
"topup.kolPendingTitle": "返佣申请审核中",
"topup.kolPendingDesc": "您的提额申请已提交,分站站长审核后会为您设置新的返佣比例。",
"topup.kolPendingTitle": "奖励申请审核中",
"topup.kolPendingDesc": "您的提额申请已提交,审核通过后会为您设置新的奖励比例。",
"topup.kolPendingBadge": "审核中",
"topup.kolApprovedTitle": "已获得自定义返佣",
"topup.kolApprovedDesc": "您当前享受的是站长审批后的自定义返佣比例。",
"topup.kolRemarkLabel": "站长备注:",
"topup.kolApprovedTitle": "已获得专属奖励",
"topup.kolApprovedDesc": "您当前享受的是审核通过后的专属奖励比例。",
"topup.kolRemarkLabel": "审核备注:",
"topup.kolRejectedLabel": "申请未通过:",
"topup.kolRejectedFallback": "暂未通过审核,您可以补充资料后重新申请。",
"topup.kolApplyModalTitle": "申请更高返佣比例",
"topup.kolApplyModalDesc": "请填写您的推广资料,站长审核通过后会为您调整返佣比例。",
"topup.kolApplyModalTitle": "申请更高奖励比例",
"topup.kolApplyModalDesc": "请填写您的推广资料,审核通过后会为您调整奖励比例。",
"topup.kolSocialLabel": "社交媒体链接",
"topup.kolSocialPlaceholder": "请输入主页、频道或账号链接",
"topup.kolFollowersLabel": "粉丝数 / 社群规模",
"topup.kolFollowersPlaceholder": "例如:5000 粉丝 / 3 个社群",
"topup.kolPlanLabel": "推广计划",
"topup.kolPlanPlaceholder": "介绍您打算如何推广本分站",
"topup.kolPlanPlaceholder": "介绍您打算如何推广当前站点",
"topup.kolContactLabel": "联系方式",
"topup.kolContactPlaceholder": "请输入微信、Telegram、邮箱等",
"topup.kolApplySubmit": "提交申请",
"topup.kolApplySuccess": "申请已提交,请等待站长审核",
"topup.kolApplySuccess": "申请已提交,请等待审核",
"topup.kolSocialRequired": "请填写社交媒体链接",
"dashboard.topupDesc": "充值账户余额",
@@ -367,11 +446,25 @@
"logs.next": "下一页",
"缓存命中 Tokens": "缓存命中 Tokens",
"缓存创建 Tokens": "缓存创建 Tokens",
"缓存创建 Tokens (5m)": "缓存创建 Tokens (5m)",
"缓存创建 Tokens (1h)": "缓存创建 Tokens (1h)",
"流式": "流式",
"是": "是",
"否": "否",
"首字时间": "首字时间",
"实际扣费来源": "实际扣费来源",
"钱包": "钱包",
"订阅": "订阅",
"分站套餐": "分站套餐",
"主站订阅": "主站订阅",
"后台订阅": "后台订阅",
"计费方式": "计费方式",
"订阅抵扣": "订阅抵扣"
"订阅抵扣": "订阅抵扣",
"定价方式": "定价方式",
"价格": "价格",
"输入价格": "输入价格",
"输出价格": "输出价格",
"缓存读取价格": "缓存读取价格",
"缓存创建价格": "缓存创建价格"
}
}
+25
View File
@@ -20,6 +20,8 @@
--page-card-border: rgba(255,255,255,0.06);
--page-input-bg: rgba(255,255,255,0.04);
--page-input-border: rgba(255,255,255,0.1);
--page-select-bg: rgba(17,24,39,0.96);
--page-select-panel-bg: rgba(17,24,39,0.98);
--page-surface: rgba(255,255,255,0.04);
--page-surface-hover: rgba(255,255,255,0.08);
--page-divider: rgba(255,255,255,0.06);
@@ -73,6 +75,8 @@
--page-card-border: rgb(229,231,235);
--page-input-bg: #fff;
--page-input-border: rgb(209,213,219);
--page-select-bg: #ffffff;
--page-select-panel-bg: #ffffff;
--page-surface: rgba(0,0,0,0.03);
--page-surface-hover: rgba(0,0,0,0.06);
--page-divider: rgb(229,231,235);
@@ -94,6 +98,8 @@
--page-link: 217 119 87;
--page-card-bg: rgba(255,255,255,0.7);
--page-card-border: #E8DDD0;
--page-select-bg: rgba(255,251,247,0.98);
--page-select-panel-bg: rgba(255,251,247,0.99);
--page-surface: rgba(61,48,36,0.04);
--page-surface-hover: rgba(61,48,36,0.08);
--page-divider: #E8DDD0;
@@ -159,6 +165,17 @@
color: rgb(var(--page-text-muted));
}
.input-solid {
background: var(--page-select-bg);
}
.select-panel {
background: var(--page-select-panel-bg);
border: 1px solid var(--page-divider);
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.28);
backdrop-filter: none;
}
.card-glow {
@apply relative overflow-hidden;
}
@@ -196,11 +213,19 @@
background: rgba(255, 255, 255, 0.97);
}
.theme-light .select-panel {
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.12);
}
.theme-claude .modal-overlay .glass,
.theme-claude .modal-overlay .glass-sm {
background: rgba(250, 246, 241, 0.97);
}
.theme-claude .select-panel {
box-shadow: 0 18px 48px rgba(107, 93, 79, 0.14);
}
/* Light theme input focus */
.theme-light .input:focus {
border-color: rgb(99 102 241);
+126 -17
View File
@@ -20,6 +20,87 @@ function getLogOther(otherStr) {
}
}
function formatAmount(symbol, rate, amount) {
return `${symbol}${(Number(amount || 0) * rate).toFixed(6)}`;
}
function getProviderSummary(other) {
if (!other?.provider_name) return '';
if (other.provider_description) {
return `${other.provider_name}:${other.provider_description}`;
}
return other.provider_name;
}
function getBillingSourceLabel(other, t) {
if (!other?.billing_source) return '';
if (other.billing_source === 'subscription') {
if (other.subscription_source === 'dist_package') return t('dashboard.packages');
if (other.subscription_source === 'order') return t('主站订阅');
if (other.subscription_source === 'admin') return t('后台订阅');
return t('订阅');
}
if (other.billing_source === 'wallet') {
return t('钱包');
}
return '';
}
function getSitePricingDetails(other, symbol, rate, t) {
if (!other?.site_billing_mode) return [];
if (other.site_billing_mode === 'per_call') {
return [
{ key: t('定价方式'), value: t('按次计费') },
{ key: t('价格'), value: formatAmount(symbol, rate, other.site_fixed_price) },
];
}
const details = [{ key: t('定价方式'), value: t('按量计费') }];
if (Number(other.site_input_price || 0) > 0) {
details.push({
key: t('输入价格'),
value: `${formatAmount(symbol, rate, other.site_input_price)} / 1M tokens`,
});
}
if (Number(other.site_output_price || 0) > 0) {
details.push({
key: t('输出价格'),
value: `${formatAmount(symbol, rate, other.site_output_price)} / 1M tokens`,
});
}
if (Number(other.site_cache_read_price || 0) > 0) {
details.push({
key: t('缓存读取价格'),
value: `${formatAmount(symbol, rate, other.site_cache_read_price)} / 1M tokens`,
});
}
const cacheCreate5m = Number(other.site_cache_creation_price_5m || 0);
const cacheCreate1h = Number(other.site_cache_creation_price_1h || 0);
const cacheCreate = Number(other.site_cache_creation_price || 0);
if (cacheCreate5m > 0 || cacheCreate1h > 0) {
const parts = [];
if (cacheCreate5m > 0) {
parts.push(`5m ${formatAmount(symbol, rate, cacheCreate5m)} / 1M tokens`);
}
if (cacheCreate1h > 0) {
parts.push(`1h ${formatAmount(symbol, rate, cacheCreate1h)} / 1M tokens`);
}
details.push({
key: t('缓存创建价格'),
value: parts.join(' / '),
});
} else if (cacheCreate > 0) {
details.push({
key: t('缓存创建价格'),
value: `${formatAmount(symbol, rate, cacheCreate)} / 1M tokens`,
});
}
return details;
}
export default function Logs() {
const { t } = useTranslation();
const { symbol, rate } = useCurrency();
@@ -55,20 +136,35 @@ export default function Logs() {
setExpandedRows(prev => ({ ...prev, [logId]: !prev[logId] }));
};
const getExpandData = (log) => {
const getExpandData = useCallback((log) => {
const other = getLogOther(log.other);
if (!other) return [];
const data = [];
const billingSourceLabel = getBillingSourceLabel(other, t);
// Cache hits
if (other.cache_tokens > 0) {
data.push({ key: t('缓存命中 Tokens'), value: other.cache_tokens.toLocaleString() });
const providerSummary = getProviderSummary(other);
if (providerSummary) {
data.push({ key: t('供应商'), value: providerSummary });
}
// Cache creation
if (billingSourceLabel) {
data.push({ key: t('实际扣费来源'), value: billingSourceLabel });
}
data.push(...getSitePricingDetails(other, symbol, rate, t));
if (other.cache_tokens > 0) {
data.push({ key: t('缓存命中 Tokens'), value: Number(other.cache_tokens).toLocaleString() });
}
if (other.cache_creation_tokens > 0) {
data.push({ key: t('缓存创建 Tokens'), value: other.cache_creation_tokens.toLocaleString() });
data.push({ key: t('缓存创建 Tokens'), value: Number(other.cache_creation_tokens).toLocaleString() });
}
if (other.cache_creation_tokens_5m > 0) {
data.push({ key: t('缓存创建 Tokens (5m)'), value: Number(other.cache_creation_tokens_5m).toLocaleString() });
}
if (other.cache_creation_tokens_1h > 0) {
data.push({ key: t('缓存创建 Tokens (1h)'), value: Number(other.cache_creation_tokens_1h).toLocaleString() });
}
// Request ID
@@ -87,13 +183,8 @@ export default function Logs() {
data.push({ key: t('首字时间'), value: `${frtSeconds}s` });
}
// Billing source
if (other.billing_source === 'subscription') {
data.push({ key: t('计费方式'), value: t('订阅抵扣') });
}
return data;
};
}, [rate, symbol, t]);
return (
<div className="max-w-6xl mx-auto px-6 py-10">
@@ -162,6 +253,7 @@ export default function Logs() {
const expandData = getExpandData(log);
const hasExpandData = expandData.length > 0;
const isExpanded = expandedRows[log.id];
const billingSourceLabel = getBillingSourceLabel(getLogOther(log.other), t);
return (
<React.Fragment key={i}>
<tr
@@ -180,8 +272,17 @@ export default function Logs() {
<td className="px-4 py-3 text-xs text-page-secondary">{log.token_name || '-'}</td>
<td className="px-4 py-3 text-right font-mono text-xs text-page-label">{log.prompt_tokens?.toLocaleString() || '0'}</td>
<td className="px-4 py-3 text-right font-mono text-xs text-page-label">{log.completion_tokens?.toLocaleString() || '0'}</td>
<td className="px-4 py-3 text-right font-mono text-xs text-page-warning">
{log.quota > 0 ? `${symbol}${(log.quota / Q * rate).toFixed(6)}` : '-'}
<td className="px-4 py-3 text-right">
<div className="flex flex-col items-end">
<span className="font-mono text-xs text-page-warning">
{log.quota > 0 ? `${symbol}${(log.quota / Q * rate).toFixed(6)}` : '-'}
</span>
{billingSourceLabel && (
<span className="text-[10px] text-page-secondary mt-1">
{billingSourceLabel}
</span>
)}
</div>
</td>
<td className="px-4 py-3 text-right text-xs text-page-secondary">
{log.use_time > 0 ? `${log.use_time}s` : '-'}
@@ -214,6 +315,7 @@ export default function Logs() {
const expandData = getExpandData(log);
const hasExpandData = expandData.length > 0;
const isExpanded = expandedRows[log.id];
const billingSourceLabel = getBillingSourceLabel(getLogOther(log.other), t);
return (
<div key={i} className="px-4 py-3 space-y-1.5">
<div
@@ -226,9 +328,16 @@ export default function Logs() {
)}
<span className="font-mono text-xs text-page font-medium">{log.model_name || '-'}</span>
</div>
<span className="font-mono text-xs text-page-warning">
{log.quota > 0 ? `${symbol}${(log.quota / Q * rate).toFixed(6)}` : '-'}
</span>
<div className="flex flex-col items-end">
<span className="font-mono text-xs text-page-warning">
{log.quota > 0 ? `${symbol}${(log.quota / Q * rate).toFixed(6)}` : '-'}
</span>
{billingSourceLabel && (
<span className="text-[10px] text-page-secondary mt-1">
{billingSourceLabel}
</span>
)}
</div>
</div>
<div className="flex items-center justify-between text-[11px] text-page-secondary">
<span>{formatTime(log.created_at)}</span>
+15 -11
View File
@@ -25,7 +25,7 @@ export default function Packages() {
const { t } = useTranslation();
const { user, refreshUser } = useAuth();
const navigate = useNavigate();
const { symbol, rate } = useCurrency();
const { symbol, rate, fmtCNY } = useCurrency();
const [packages, setPackages] = useState([]);
const [models, setModels] = useState([]);
const [loading, setLoading] = useState(true);
@@ -69,11 +69,14 @@ export default function Packages() {
if (res.data.success) {
toast.success(t('packages.subscribedSuccess'));
setConfirmPkg(null);
await refreshUser();
// Reload subscriptions
getActiveSubscriptions()
.then((r) => { if (r.data.success) setActiveSubs(r.data.data || []); })
.catch(() => {});
// Background refresh errors shouldn't override a successful purchase toast.
await refreshUser({ skipErrorHandler: true }).catch(() => null);
const subsRes = await getActiveSubscriptions({
skipErrorHandler: true,
}).catch(() => null);
if (subsRes?.data?.success) {
setActiveSubs(subsRes.data.data || []);
}
} else {
toast.error(res.data.message || t('common.requestFailed'));
}
@@ -206,9 +209,9 @@ export default function Packages() {
{/* Price */}
<div className="mb-6">
<div className="flex items-baseline gap-2">
<span className="text-4xl font-bold text-page">{symbol}{Number(pkg.price).toFixed(2)}</span>
<span className="text-4xl font-bold text-page">{fmtCNY(pkg.price)}</span>
{pkg.original_price > 0 && pkg.original_price > pkg.price && (
<span className="text-lg text-page-muted line-through">{symbol}{Number(pkg.original_price).toFixed(2)}</span>
<span className="text-lg text-page-muted line-through">{fmtCNY(pkg.original_price)}</span>
)}
</div>
{pkg.duration > 0 && (
@@ -236,8 +239,8 @@ export default function Packages() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
{isSubscription
? t('packages.periodicQuota', { amount: (pkg.quota_amount / Q * rate).toFixed(2), period: getResetLabel(resetPeriod) })
: t('packages.creditIncluded', { amount: (pkg.quota_amount / Q * rate).toFixed(2) })
? t('packages.periodicQuota', { symbol, amount: (pkg.quota_amount / Q * rate).toFixed(2), period: getResetLabel(resetPeriod) })
: t('packages.creditIncluded', { symbol, amount: (pkg.quota_amount / Q * rate).toFixed(2) })
}
</li>
)}
@@ -289,12 +292,13 @@ export default function Packages() {
<div className="glass rounded-2xl p-6 w-full max-w-sm" onClick={(e) => e.stopPropagation()}>
<h2 className="text-lg font-semibold text-page mb-3">{t('packages.confirmTitle')}</h2>
<p className="text-sm text-page-secondary mb-2">
{t('packages.confirmDesc', { name: confirmPkg.name, price: pkgPrice.toFixed(2) })}
{t('packages.confirmDesc', { name: confirmPkg.name, price: fmtCNY(pkgPrice) })}
</p>
{isSubscription && (
<div className="bg-purple-500/10 border border-purple-500/20 rounded-xl p-3 mb-3">
<p className="text-xs text-page-info">
{t('packages.subscriptionInfo', {
symbol,
period: getResetLabel(resetPeriod),
days: confirmPkg.duration || 30,
amount: (confirmPkg.quota_amount / Q * rate).toFixed(2),
+18 -1
View File
@@ -60,6 +60,15 @@ export default function Pricing() {
const formatTokenPrice = (price) =>
price != null ? `${symbol}${(Number(price) * 1000 * rate).toFixed(4)}` : '-';
const formatCacheCreationPrice = (modelName, price, price1h) => {
if (price == null) return '-';
const supportsDualCacheWindow = (modelName || '').toLowerCase().includes('claude');
if (supportsDualCacheWindow && price1h != null && Math.abs(Number(price1h) - Number(price)) > 1e-12) {
return `${t('pricing.cacheCreation5m')} ${formatTokenPrice(price)} / ${t('pricing.cacheCreation1h')} ${formatTokenPrice(price1h)}`;
}
return formatTokenPrice(price);
};
const formatPerCallPrice = (price) =>
price != null
? `${symbol}${(Number(price) * rate).toFixed(4)}/${t('pricing.perCallUnit')}`
@@ -132,13 +141,15 @@ export default function Pricing() {
{search || vendor ? t('pricing.noMatch') : t('pricing.noModels')}
</div>
) : (
<div className="glass-sm rounded-xl overflow-hidden">
<div className="glass-sm rounded-xl overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-page-divider">
<th className="text-left px-5 py-3.5 font-medium text-page-secondary">{t('pricing.model')}</th>
<th className="text-right px-5 py-3.5 font-medium text-page-secondary">{t('pricing.inputPrice')}</th>
<th className="text-right px-5 py-3.5 font-medium text-page-secondary">{t('pricing.outputPrice')}</th>
<th className="text-right px-5 py-3.5 font-medium text-page-secondary">{t('pricing.cacheReadPrice')}</th>
<th className="text-right px-5 py-3.5 font-medium text-page-secondary">{t('pricing.cacheCreationPrice')}</th>
<th className="text-center px-5 py-3.5 font-medium text-page-secondary">{t('pricing.status')}</th>
</tr>
</thead>
@@ -154,6 +165,12 @@ export default function Pricing() {
<td className="px-5 py-3.5 text-right font-mono text-page-label">
{m.is_per_call ? formatPerCallPrice(m.fixed_price) : formatTokenPrice(m.output_price)}
</td>
<td className="px-5 py-3.5 text-right font-mono text-page-label">
{m.is_per_call ? '-' : formatTokenPrice(m.cache_read_price)}
</td>
<td className="px-5 py-3.5 text-right font-mono text-page-label whitespace-nowrap">
{m.is_per_call ? '-' : formatCacheCreationPrice(m.model_name, m.cache_creation_price, m.cache_creation_price_1h)}
</td>
<td className="px-5 py-3.5 text-center">
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs border ${
m.status === 'healthy'
+69 -8
View File
@@ -1,15 +1,15 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import toast from 'react-hot-toast';
import { createSubDistributorOrder, getSubDistributorInfo } from '../api';
import { useAuth } from '../context/AuthContext';
import { useSite } from '../context/SiteContext';
import { useSite, useCurrency } from '../context/SiteContext';
function submitEpayForm(resData) {
const params = resData.data;
const url = resData.url;
if (!params || !url) return;
if (!params || !url) return false;
const form = document.createElement('form');
form.action = url;
form.method = 'POST';
@@ -27,12 +27,16 @@ function submitEpayForm(resData) {
document.body.appendChild(form);
form.submit();
document.body.removeChild(form);
return true;
}
export default function SubDistributor() {
const { t } = useTranslation();
const { user } = useAuth();
const { user, refreshUser, loading: authLoading } = useAuth();
const { site } = useSite();
const { symbol, fmtCNY } = useCurrency();
const location = useLocation();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [subInfo, setSubInfo] = useState(null);
const [submitting, setSubmitting] = useState(false);
@@ -65,6 +69,41 @@ export default function SubDistributor() {
() => paymentMethods.find((item) => item.type === form.payment_method),
[paymentMethods, form.payment_method]
);
const paymentReturned = useMemo(
() => new URLSearchParams(location.search).get('payment') === 'return',
[location.search]
);
useEffect(() => {
if (!paymentReturned || authLoading) return;
let cancelled = false;
const toastId = 'sub-dist-payment-return';
const checkPaymentResult = async () => {
if (!user) {
toast(t('subDist.paymentPending'), { id: toastId });
navigate('/sub-site', { replace: true });
return;
}
toast.loading(t('subDist.confirmingPayment'), { id: toastId });
const refreshed = await refreshUser({ skipErrorHandler: true });
if (cancelled) return;
if (refreshed?.has_distributor) {
toast.success(t('subDist.openedSuccess'), { id: toastId });
} else {
toast(t('subDist.paymentPending'), { id: toastId });
}
navigate('/sub-site', { replace: true });
};
checkPaymentResult();
return () => {
cancelled = true;
};
}, [authLoading, navigate, paymentReturned, refreshUser, t, user]);
const handleSubmit = async (e) => {
e.preventDefault();
@@ -88,6 +127,7 @@ export default function SubDistributor() {
name: form.name.trim(),
slug: form.slug.trim().toLowerCase(),
payment_method: form.payment_method,
return_url: `${window.location.origin}/sub-site?payment=return`,
};
if (form.payment_method === 'crypto') {
payload.chain = form.chain;
@@ -96,14 +136,26 @@ export default function SubDistributor() {
const res = await createSubDistributorOrder(payload);
if (res.data.message === 'success') {
if (res.data.payment_type === 'stripe' && res.data.data?.pay_link) {
window.open(res.data.data.pay_link, '_blank');
const opened = window.open(res.data.data.pay_link, '_blank');
if (opened) {
toast.success(t('subDist.paymentPageOpened'));
} else {
toast.error(t('subDist.popupBlocked'));
}
} else if (res.data.payment_type === 'crypto') {
setCryptoOrder(res.data.data);
toast.success(t('subDist.cryptoOrderCreated'));
} else {
submitEpayForm(res.data);
if (submitEpayForm(res.data)) {
toast.success(t('subDist.paymentPageOpened'));
} else {
toast.error(t('subDist.paymentPageFailed'));
}
}
} else if (res.data.data) {
toast.error(typeof res.data.data === 'string' ? res.data.data : t('subDist.createFailed'));
} else {
toast.error(t('subDist.createFailed'));
}
} catch (e) {
// handled by interceptor
@@ -144,6 +196,15 @@ export default function SubDistributor() {
<Link to="/register" className="btn-secondary">{t('subDist.goRegister')}</Link>
</div>
</div>
) : user?.has_distributor ? (
<div className="rounded-2xl border border-emerald-500/20 bg-emerald-500/5 p-5 space-y-3">
<p className="text-sm font-medium text-page">{t('subDist.alreadyOpenTitle')}</p>
<p className="text-sm text-page-secondary">
{t('subDist.alreadyOpenDesc', {
name: user.distributor_name || user.distributor_slug || t('subDist.defaultSiteName'),
})}
</p>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-5">
<div>
@@ -215,7 +276,7 @@ export default function SubDistributor() {
<button type="submit" disabled={submitting} className="btn-primary w-full justify-center flex items-center gap-2">
{submitting && <span className="w-4 h-4 rounded-full border-2 border-white/30 border-t-white animate-spin" />}
{t('subDist.payAndOpen', { price: Number(subInfo?.price || 0).toFixed(2) })}
{t('subDist.payAndOpen', { symbol, price: Number(subInfo?.price || 0).toFixed(2) })}
</button>
<p className="text-xs text-page-muted">
@@ -246,7 +307,7 @@ export default function SubDistributor() {
<div className="space-y-4">
<div className="flex items-center justify-between text-sm">
<span className="text-page-secondary">{t('subDist.openPrice')}</span>
<span className="text-2xl font-bold text-page">¥{Number(subInfo?.price || 0).toFixed(2)}</span>
<span className="text-2xl font-bold text-page">{fmtCNY(subInfo?.price || 0)}</span>
</div>
<p className="text-sm text-page-secondary leading-6">{t('subDist.priceSummary')}</p>
</div>
+459 -14
View File
@@ -1,18 +1,35 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { getTokens, createToken, updateToken, deleteToken, getSiteKeyGroups } from '../api';
import {
getTokens,
createToken,
updateToken,
deleteToken,
getSiteKeyGroups,
getSiteKeyGroupPricing,
getTokenSupportedModels,
} from '../api';
import ConfigExporter from '../components/ConfigExporter';
import { useCurrency } from '../context/SiteContext';
import toast from 'react-hot-toast';
export default function Tokens() {
const { t } = useTranslation();
const { symbol, rate } = useCurrency();
const [tokens, setTokens] = useState([]);
const [loading, setLoading] = useState(true);
const [copiedId, setCopiedId] = useState(null);
const [newKey, setNewKey] = useState(null);
const [deleteConfirm, setDeleteConfirm] = useState(null);
const [expandedTokens, setExpandedTokens] = useState({});
const [tokenModels, setTokenModels] = useState({});
// Key groups
const [keyGroups, setKeyGroups] = useState([]);
const [activePricingGroup, setActivePricingGroup] = useState(null);
const [groupPricingCache, setGroupPricingCache] = useState({});
const [loadingGroupPricingId, setLoadingGroupPricingId] = useState(0);
const [groupPricingSearch, setGroupPricingSearch] = useState('');
// Create modal
const [showCreate, setShowCreate] = useState(false);
@@ -59,6 +76,30 @@ export default function Tokens() {
setShowCreate(true);
};
const openGroupPricing = async (group) => {
setActivePricingGroup(group);
setGroupPricingSearch('');
if (groupPricingCache[group.id] || loadingGroupPricingId === group.id) {
return;
}
setLoadingGroupPricingId(group.id);
try {
const res = await getSiteKeyGroupPricing(group.id);
if (res.data.success) {
setGroupPricingCache((prev) => ({
...prev,
[group.id]: res.data.data || { items: [], summary: null, group },
}));
}
} catch (e) { /* interceptor */ }
setLoadingGroupPricingId(0);
};
const closeGroupPricing = () => {
setActivePricingGroup(null);
setGroupPricingSearch('');
};
const handleCreate = async (e) => {
e.preventDefault();
if (!createName.trim()) {
@@ -128,6 +169,65 @@ export default function Tokens() {
try { return JSON.parse(tagsStr || '[]'); } catch { return []; }
};
const handleToggleSupportedModels = async (tokenId) => {
const isExpanded = !!expandedTokens[tokenId];
setExpandedTokens((prev) => ({ ...prev, [tokenId]: !isExpanded }));
if (isExpanded || tokenModels[tokenId]) return;
setTokenModels((prev) => ({
...prev,
[tokenId]: { loading: true, models: [], count: 0, provider_names: [], restricted_by_providers: false, restricted_by_models: false },
}));
try {
const res = await getTokenSupportedModels(tokenId);
if (res.data.success) {
const data = res.data.data || {};
setTokenModels((prev) => ({
...prev,
[tokenId]: {
loading: false,
models: data.models || [],
count: data.count || 0,
provider_names: data.provider_names || [],
restricted_by_providers: Boolean(data.restricted_by_providers),
restricted_by_models: Boolean(data.restricted_by_models),
},
}));
} else {
setTokenModels((prev) => ({
...prev,
[tokenId]: { loading: false, error: true, models: [], count: 0, provider_names: [], restricted_by_providers: false, restricted_by_models: false },
}));
}
} catch (e) {
setTokenModels((prev) => ({
...prev,
[tokenId]: { loading: false, error: true, models: [], count: 0, provider_names: [], restricted_by_providers: false, restricted_by_models: false },
}));
}
};
const hasGroups = keyGroups.length > 0;
const activeGroupPricing = activePricingGroup
? groupPricingCache[activePricingGroup.id] || null
: null;
const filteredGroupPricingItems = useMemo(() => {
const items = activeGroupPricing?.items || [];
const keyword = groupPricingSearch.trim().toLowerCase();
if (!keyword) return items;
return items.filter((item) => {
const modelName = (item.model_name || '').toLowerCase();
const displayName = (item.display_name || '').toLowerCase();
const category = (item.category || '').toLowerCase();
return (
modelName.includes(keyword) ||
displayName.includes(keyword) ||
category.includes(keyword)
);
});
}, [activeGroupPricing, groupPricingSearch]);
if (loading) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
@@ -136,8 +236,6 @@ export default function Tokens() {
);
}
const hasGroups = keyGroups.length > 0;
return (
<div className="max-w-5xl mx-auto px-6 py-10">
@@ -189,6 +287,7 @@ export default function Tokens() {
group={group}
parseTags={parseTags}
onSelect={openCreateFromGroup}
onViewPricing={openGroupPricing}
t={t}
/>
))}
@@ -237,6 +336,20 @@ export default function Tokens() {
</div>
)}
<GroupPricingModal
open={!!activePricingGroup}
group={activePricingGroup}
pricingData={activeGroupPricing}
items={filteredGroupPricingItems}
loading={loadingGroupPricingId === activePricingGroup?.id}
search={groupPricingSearch}
onSearchChange={setGroupPricingSearch}
onClose={closeGroupPricing}
symbol={symbol}
rate={rate}
t={t}
/>
{/* ========== New Key Reveal Modal ========== */}
{newKey && (
<div className="modal-overlay fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
@@ -314,6 +427,12 @@ export default function Tokens() {
{token.created_time ? new Date(token.created_time * 1000).toLocaleDateString() : ''}
</span>
<div className="flex items-center gap-2">
<button
onClick={() => handleToggleSupportedModels(token.id)}
className="px-3 py-1 text-xs rounded-lg border border-page-divider text-page-secondary hover:bg-page-surface-hover transition-colors"
>
{expandedTokens[token.id] ? t('tokens.hideSupportedModels') : t('tokens.viewSupportedModels')}
</button>
<button
onClick={() => handleToggle(token)}
className={`px-3 py-1 text-xs rounded-lg border transition-colors ${
@@ -345,17 +464,72 @@ export default function Tokens() {
</button>
</div>
)}
{expandedTokens[token.id] && (
<div className="mt-3 rounded-xl border border-page-divider bg-page-surface/50 px-4 py-3">
{tokenModels[token.id]?.loading ? (
<div className="flex items-center gap-2 text-sm text-page-secondary">
<div className="w-4 h-4 border-2 border-brand-500/30 border-t-brand-500 rounded-full animate-spin" />
<span>{t('tokens.loadingSupportedModels')}</span>
</div>
) : tokenModels[token.id]?.error ? (
<p className="text-sm text-page-danger">{t('tokens.loadSupportedModelsFailed')}</p>
) : (
<>
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-medium text-page">
{t('tokens.supportedModels')} ({tokenModels[token.id]?.count || 0})
</p>
{tokenModels[token.id]?.restricted_by_models && (
<span className="px-2 py-0.5 rounded-full text-[11px] bg-brand-500/10 text-brand-500">
{t('tokens.restrictedByModels')}
</span>
)}
{tokenModels[token.id]?.restricted_by_providers && (
<span className="px-2 py-0.5 rounded-full text-[11px] bg-brand-500/10 text-brand-500">
{t('tokens.restrictedByProviders')}
</span>
)}
</div>
{tokenModels[token.id]?.provider_names?.length > 0 && (
<div className="mt-2 flex flex-wrap items-center gap-2">
<span className="text-xs text-page-muted">{t('tokens.supportedProviders')}</span>
{tokenModels[token.id].provider_names.map((name) => (
<span key={name} className="px-2 py-0.5 rounded-full text-[11px] bg-page-inset text-page-secondary">
{name}
</span>
))}
</div>
)}
{tokenModels[token.id]?.models?.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{tokenModels[token.id].models.map((modelName) => (
<code key={modelName} className="px-2.5 py-1 rounded-lg text-[11px] font-mono bg-page-inset text-page-secondary">
{modelName}
</code>
))}
</div>
) : (
<p className="mt-3 text-sm text-page-muted">{t('tokens.noSupportedModels')}</p>
)}
</>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
<div className="mt-8">
<ConfigExporter tokens={tokens} />
</div>
</div>
);
}
/* ========== Key Group Card ========== */
function KeyGroupCard({ group, parseTags, onSelect, t }) {
function KeyGroupCard({ group, parseTags, onSelect, onViewPricing, t }) {
const tags = parseTags(group.tags);
const isUnavailable = group.is_unavailable;
@@ -363,7 +537,7 @@ function KeyGroupCard({ group, parseTags, onSelect, t }) {
<div
className={`glass-sm rounded-xl p-4 border border-page-divider transition-all ${
isUnavailable
? 'opacity-50 cursor-not-allowed'
? 'opacity-75'
: 'hover:border-brand-500/40 cursor-pointer group'
}`}
onClick={() => !isUnavailable && onSelect(group)}
@@ -410,16 +584,287 @@ function KeyGroupCard({ group, parseTags, onSelect, t }) {
)}
</div>
{/* Create arrow */}
{!isUnavailable && (
<div className="flex items-center gap-1.5 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-xs font-medium text-brand-500">{t('tokens.create')}</span>
<svg className="w-4 h-4 text-brand-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
)}
<div className="flex flex-col items-end gap-2 flex-shrink-0">
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onViewPricing(group);
}}
className="px-3 py-1.5 text-xs rounded-lg border border-page-divider text-page-secondary hover:bg-page-surface-hover hover:text-page transition-colors"
>
{t('tokens.viewGroupPricing')}
</button>
{!isUnavailable && (
<div className="flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-xs font-medium text-brand-500">{t('tokens.create')}</span>
<svg className="w-4 h-4 text-brand-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
)}
</div>
</div>
</div>
);
}
function GroupPricingModal({
open,
group,
pricingData,
items,
loading,
search,
onSearchChange,
onClose,
symbol,
rate,
t,
}) {
if (!open || !group) {
return null;
}
const displayGroup = pricingData?.group || group;
const summary = pricingData?.summary;
const hasItems = (pricingData?.items || []).length > 0;
return (
<div
className="modal-overlay fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
onClick={onClose}
>
<div
className="glass rounded-2xl w-full max-w-6xl max-h-[88vh] overflow-hidden"
onClick={(event) => event.stopPropagation()}
>
<div className="px-6 py-5 border-b border-page-divider">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-xl font-heading font-semibold text-page">
{displayGroup.name} · {t('tokens.groupPricingTitle')}
</h2>
<p className="text-sm text-page-secondary mt-1 max-w-3xl">
{t('tokens.groupPricingSubtitle')}
</p>
</div>
<button
type="button"
onClick={onClose}
className="px-3 py-1.5 text-sm rounded-lg border border-page-divider text-page-secondary hover:bg-page-surface-hover transition-colors"
>
{t('tokens.cancel')}
</button>
</div>
<div className="flex flex-wrap items-center gap-2 mt-4">
{displayGroup.discount_label && (
<span className="px-2.5 py-1 rounded-full text-xs font-semibold bg-green-500/10 text-page-success">
{displayGroup.discount_label}
</span>
)}
{displayGroup.rmb_per_usd > 0 && (
<span className="px-2.5 py-1 rounded-full text-xs bg-page-surface text-page-secondary">
{displayGroup.rmb_per_usd} {t('tokens.rmbPerUsd')}
</span>
)}
{summary && (
<span className="px-2.5 py-1 rounded-full text-xs bg-page-surface text-page-secondary">
{t('tokens.groupPricingAvailableLines')}: {summary.provider_count}
</span>
)}
{summary && (
<span className="px-2.5 py-1 rounded-full text-xs bg-page-surface text-page-secondary">
{t('tokens.groupPricingAvailableModels')}: {summary.model_count}
</span>
)}
{summary?.provider_limited && (
<span className="px-2.5 py-1 rounded-full text-xs bg-brand-500/10 text-brand-500">
{t('tokens.restrictedByProviders')}
</span>
)}
{summary?.model_limited && (
<span className="px-2.5 py-1 rounded-full text-xs bg-brand-500/10 text-brand-500">
{t('tokens.restrictedByModels')}
</span>
)}
</div>
{displayGroup.description && (
<p className="text-sm text-page-secondary mt-3">
{displayGroup.description}
</p>
)}
</div>
<div className="px-6 py-4 border-b border-page-divider bg-page-surface/40">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<p className="text-sm text-page-secondary">
{t('tokens.groupPricingNotice')}
</p>
<input
type="text"
value={search}
onChange={(event) => onSearchChange(event.target.value)}
className="input lg:max-w-xs"
placeholder={t('tokens.groupPricingSearchPlaceholder')}
/>
</div>
</div>
<div className="px-6 py-5 overflow-y-auto max-h-[58vh]">
{loading ? (
<div className="flex items-center gap-2 text-sm text-page-secondary">
<div className="w-4 h-4 border-2 border-brand-500/30 border-t-brand-500 rounded-full animate-spin" />
<span>{t('tokens.groupPricingLoading')}</span>
</div>
) : !hasItems ? (
<div className="text-sm text-page-secondary">
{t('tokens.groupPricingNoData')}
</div>
) : items.length === 0 ? (
<div className="text-sm text-page-secondary">
{t('tokens.groupPricingNoMatch')}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm min-w-[860px]">
<thead>
<tr className="border-b border-page-divider">
<th className="text-left px-4 py-3 font-medium text-page-secondary">{t('pricing.model')}</th>
<th className="text-left px-4 py-3 font-medium text-page-secondary">{t('定价方式')}</th>
<th className="text-right px-4 py-3 font-medium text-page-secondary">{t('tokens.groupPricingReferencePrice')}</th>
<th className="text-right px-4 py-3 font-medium text-page-secondary">{t('pricing.outputPrice')}</th>
<th className="text-right px-4 py-3 font-medium text-page-secondary">{t('pricing.cacheReadPrice')}</th>
<th className="text-right px-4 py-3 font-medium text-page-secondary">{t('pricing.cacheCreationPrice')}</th>
<th className="text-center px-4 py-3 font-medium text-page-secondary">{t('tokens.groupPricingLines')}</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={`${item.model_name}:${item.billing_type}`} className="border-b border-page-divider last:border-0 align-top">
<td className="px-4 py-3.5">
<div className="min-w-0">
<div className="font-medium text-page">{item.display_name || item.model_name}</div>
{(item.display_name || item.model_name) !== item.model_name && (
<div className="text-xs text-page-muted font-mono mt-1">{item.model_name}</div>
)}
{item.category && (
<div className="text-xs text-page-muted mt-1 uppercase tracking-wide">{item.category}</div>
)}
</div>
</td>
<td className="px-4 py-3.5 text-page-secondary">
{item.billing_type === 'per_call' ? t('pricing.perCall') : t('按量计费')}
</td>
<td className="px-4 py-3.5 text-right font-mono text-page-label whitespace-nowrap">
{item.status !== 'healthy'
? t('pricing.unknown')
: item.billing_type === 'per_call'
? formatGroupPriceRange(item.fixed_price_min, item.fixed_price_max, symbol, rate, true, t)
: formatGroupPriceRange(item.input_price_min, item.input_price_max, symbol, rate, false, t)}
</td>
<td className="px-4 py-3.5 text-right font-mono text-page-label whitespace-nowrap">
{item.status !== 'healthy'
? '-'
: item.billing_type === 'per_call'
? '-'
: formatGroupPriceRange(item.output_price_min, item.output_price_max, symbol, rate, false, t)}
</td>
<td className="px-4 py-3.5 text-right font-mono text-page-label whitespace-nowrap">
{item.status !== 'healthy'
? '-'
: item.billing_type === 'per_call'
? '-'
: formatGroupPriceRange(item.cache_read_price_min, item.cache_read_price_max, symbol, rate, false, t)}
</td>
<td className="px-4 py-3.5 text-right font-mono text-page-label whitespace-nowrap">
{item.status !== 'healthy'
? '-'
: item.billing_type === 'per_call'
? '-'
: formatGroupCachePriceRange(item, symbol, rate, t)}
</td>
<td className="px-4 py-3.5 text-center">
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-page-surface text-page-secondary">
{formatRouteCount(item.route_count, item.has_range, t)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
);
}
function formatGroupPriceRange(min, max, symbol, rate, perCall, t) {
if (min == null && max == null) {
return '-';
}
const low = Number(min ?? max ?? 0);
const high = Number(max ?? min ?? 0);
const factor = perCall ? rate : rate * 1000;
const suffix = perCall ? `/${t('pricing.perCallUnit')}` : '';
const lowText = `${symbol}${(low * factor).toFixed(4)}`;
const highText = `${symbol}${(high * factor).toFixed(4)}`;
if (Math.abs(low - high) <= 1e-9) {
return `${lowText}${suffix}`;
}
return `${lowText} - ${highText}${suffix}`;
}
function formatGroupCachePriceRange(item, symbol, rate, t) {
const base = formatGroupPriceRange(
item.cache_creation_price_min,
item.cache_creation_price_max,
symbol,
rate,
false,
t,
);
if (
item.cache_creation_price_1h_min == null &&
item.cache_creation_price_1h_max == null
) {
return base;
}
const baseMin = Number(item.cache_creation_price_min ?? 0);
const baseMax = Number(item.cache_creation_price_max ?? 0);
const oneHourMin = Number(item.cache_creation_price_1h_min ?? 0);
const oneHourMax = Number(item.cache_creation_price_1h_max ?? 0);
if (
Math.abs(baseMin - oneHourMin) <= 1e-9 &&
Math.abs(baseMax - oneHourMax) <= 1e-9
) {
return base;
}
const oneHour = formatGroupPriceRange(
item.cache_creation_price_1h_min,
item.cache_creation_price_1h_max,
symbol,
rate,
false,
t,
);
return `${t('pricing.cacheCreation5m')} ${base} / ${t('pricing.cacheCreation1h')} ${oneHour}`;
}
function formatRouteCount(routeCount, hasRange, t) {
if (!routeCount) {
return t('pricing.unknown');
}
if (routeCount === 1) {
return `1 ${t('tokens.groupPricingLineUnitSingle')}`;
}
if (!hasRange) {
return `${routeCount} ${t('tokens.groupPricingLineUnit')} · ${t('tokens.groupPricingSamePrice')}`;
}
return `${routeCount} ${t('tokens.groupPricingLineUnit')}`;
}
+73 -70
View File
@@ -3,10 +3,10 @@ import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { useSite } from '../context/SiteContext';
import {
getUserUsage, redeemCode, getTopupInfo, calculateAmount,
getUserUsage, redeemCode, getTopupInfo,
createEpayOrder, createStripeOrder, createCreemOrder,
createCryptoOrder, getCryptoOrderStatus, getTopupHistory,
Q, quotaToDollar,
Q,
} from '../api';
import { useCurrency } from '../context/SiteContext';
import CountUp from '../components/bits/CountUp';
@@ -30,8 +30,6 @@ export default function Topup() {
const [amount, setAmount] = useState('');
const [displayAmount, setDisplayAmount] = useState('');
const [selectedPreset, setSelectedPreset] = useState(null);
const [payAmount, setPayAmount] = useState(null);
const [amountLoading, setAmountLoading] = useState(false);
const [paymentLoading, setPaymentLoading] = useState(false);
const [payingMethod, setPayingMethod] = useState('');
@@ -112,25 +110,11 @@ export default function Topup() {
setRedeeming(false);
};
// Calculate amount
const calcAmount = async (val) => {
if (!val || val <= 0) { setPayAmount(null); return; }
setAmountLoading(true);
try {
const res = await calculateAmount({ amount: parseInt(val) });
if (res.data.message === 'success') {
setPayAmount(res.data.data);
}
} catch (e) { /* interceptor */ }
setAmountLoading(false);
};
// Select preset
const handlePreset = (val) => {
setSelectedPreset(val);
setAmount(String(val));
setDisplayAmount(toDisplayAmount(val));
calcAmount(val);
};
// Determine if a payment method is Stripe-based
@@ -286,6 +270,15 @@ export default function Topup() {
if (cryptoWallets.bsc) chains.push({ key: 'bsc', label: 'BSC (BEP20)' });
return chains;
}, [cryptoWallets.tron, cryptoWallets.eth, cryptoWallets.bsc]);
const selectedChainMeta = useMemo(
() => availableChains.find((chain) => chain.key === selectedChain) || null,
[availableChains, selectedChain],
);
const selectedChainLabel = selectedChainMeta?.label || '';
const selectedTokenLabel = selectedToken.toUpperCase();
const selectedCryptoLabel = selectedChainLabel
? `${selectedTokenLabel} (${selectedChainLabel})`
: selectedTokenLabel;
// Set default chain when available
useEffect(() => {
@@ -409,19 +402,16 @@ export default function Topup() {
setSelectedPreset(null);
const quotaAmount = toQuotaAmount(currentValue);
setAmount(quotaAmount === '' ? '' : String(quotaAmount));
calcAmount(quotaAmount);
}}
onBlur={(e) => {
const quotaAmount = toQuotaAmount(e.target.value);
if (quotaAmount === '') {
setDisplayAmount('');
setAmount('');
setPayAmount(null);
return;
}
setAmount(String(quotaAmount));
setDisplayAmount(toDisplayAmount(quotaAmount));
calcAmount(quotaAmount);
}}
min={minTopup * rate}
step="0.01"
@@ -432,18 +422,7 @@ export default function Topup() {
<p className="text-xs text-page-muted mt-2">
{t('topup.customAmountHint')}
</p>
{amountLoading ? (
<p className="text-xs text-page-muted mt-2">{t('topup.calculating')}</p>
) : payAmount ? (
<div className="mt-2 space-y-1 text-xs text-page-secondary">
<p>
{t('topup.rechargeAmountLabel')}: <span className="text-page font-medium">{symbol}{displayAmount || toDisplayAmount(amount)}</span>
</p>
<p>
{t('topup.payAmountLabel')}: <span className="text-page-success font-medium">{symbol}{payAmount}</span>
</p>
</div>
) : amount ? (
{amount ? (
<p className="text-xs text-page-muted mt-2">
{t('topup.rechargeAmountLabel')}: {symbol}{displayAmount || toDisplayAmount(amount)}
</p>
@@ -474,48 +453,72 @@ export default function Topup() {
{enableCrypto && availableChains.length > 0 && (
<div className="mt-6 pt-6 border-t border-page-divider">
<label className="block text-sm font-medium text-page-label mb-3">{t('topup.cryptoPayment')}</label>
<div className="flex flex-wrap items-center gap-3">
{/* Chain */}
<div className="flex gap-1.5">
{availableChains.map((chain) => (
<button
key={chain.key}
onClick={() => setSelectedChain(chain.key)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
selectedChain === chain.key
? 'bg-brand-500 text-white shadow-lg shadow-brand-500/25'
: 'glass-sm text-page-label hover:text-page hover:bg-page-surface-hover'
}`}
>
{chain.label}
</button>
))}
<div className="rounded-xl border border-page-divider bg-page-surface/50 p-4 space-y-4">
<p className="text-xs text-page-muted">
{t('topup.cryptoSelectionHint')}
</p>
<div>
<p className="text-xs font-medium text-page-label mb-2">
{t('topup.cryptoStepChain')}
</p>
<div className="flex flex-wrap gap-1.5">
{availableChains.map((chain) => (
<button
key={chain.key}
onClick={() => setSelectedChain(chain.key)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
selectedChain === chain.key
? 'bg-brand-500 text-white shadow-lg shadow-brand-500/25'
: 'glass-sm text-page-label hover:text-page hover:bg-page-surface-hover'
}`}
>
{chain.label}
</button>
))}
</div>
</div>
{/* Token */}
<div className="flex gap-1.5">
{['usdt', 'usdc'].map((token) => (
<button
key={token}
onClick={() => setSelectedToken(token)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
selectedToken === token
? 'bg-brand-500 text-white shadow-lg shadow-brand-500/25'
: 'glass-sm text-page-label hover:text-page hover:bg-page-surface-hover'
}`}
>
{token.toUpperCase()}
</button>
))}
<div>
<p className="text-xs font-medium text-page-label mb-2">
{t('topup.cryptoStepToken')}
</p>
<div className="flex flex-wrap gap-1.5">
{['usdt', 'usdc'].map((token) => (
<button
key={token}
onClick={() => setSelectedToken(token)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
selectedToken === token
? 'bg-brand-500 text-white shadow-lg shadow-brand-500/25'
: 'glass-sm text-page-label hover:text-page hover:bg-page-surface-hover'
}`}
>
{token.toUpperCase()}
</button>
))}
</div>
</div>
{/* Pay */}
<div className="rounded-lg bg-page-inset/60 px-3 py-3">
<p className="text-[11px] text-page-muted mb-1">
{t('topup.cryptoSelectedSummary')}
</p>
<p className="text-sm font-medium text-page">
{selectedCryptoLabel}
</p>
</div>
<button
onClick={handleCryptoPay}
disabled={paymentLoading || !amount}
className="px-4 py-2.5 rounded-xl text-sm font-medium glass-sm text-page-label hover:text-page hover:bg-page-surface-hover disabled:opacity-40 disabled:cursor-not-allowed transition-all"
className="btn-primary w-full justify-center flex items-center gap-2 disabled:opacity-40 disabled:cursor-not-allowed"
>
{paymentLoading && payingMethod === 'crypto'
? t('topup.processing')
: `${selectedToken.toUpperCase()} (${availableChains.find(c => c.key === selectedChain)?.label || selectedChain})`}
{paymentLoading && payingMethod === 'crypto' ? (
t('topup.processing')
) : (
t('topup.generateCryptoAddress', { method: selectedCryptoLabel })
)}
</button>
</div>
</div>
@@ -581,7 +584,7 @@ export default function Topup() {
</div>
<div className="glass-sm rounded-xl p-3">
<p className="text-xs text-page-secondary mb-1">{t('topup.chain')}</p>
<p className="text-sm text-page font-medium">{cryptoOrder.chain}</p>
<p className="text-sm text-page font-medium">{selectedChainLabel || cryptoOrder.chain}</p>
</div>
</div>
{cryptoPolling && (
+6 -3
View File
@@ -8,12 +8,13 @@ import { calcOfficialEquivList } from '../../utils/officialEquiv';
import RotatingEquiv from '../../components/bits/RotatingEquiv';
import CountUp from '../../components/bits/CountUp';
import FadeContent from '../../components/bits/FadeContent';
import ApiEndpoints from '../../components/ApiEndpoints';
export default function ClaudeHome() {
const { t } = useTranslation();
const { user } = useAuth();
const { site } = useSite();
const { symbol, rate } = useCurrency();
const { symbol, rate, fmtCNY } = useCurrency();
const [models, setModels] = useState([]);
const [packages, setPackages] = useState([]);
@@ -81,6 +82,8 @@ export default function ClaudeHome() {
</FadeContent>
</section>
<ApiEndpoints />
{/* Features */}
<section className="bg-[#F5EEE6]">
<div className="max-w-5xl mx-auto px-6 py-20">
@@ -160,9 +163,9 @@ export default function ClaudeHome() {
<h3 className="text-base font-semibold text-[#3D3024]">{pkg.name}</h3>
{pkg.description && <p className="text-sm text-[#8B7D6E] mt-1">{pkg.description}</p>}
<div className="mt-auto pt-6">
<span className="text-3xl font-bold text-[#3D3024]">{symbol}{Number(pkg.price).toFixed(2)}</span>
<span className="text-3xl font-bold text-[#3D3024]">{fmtCNY(pkg.price)}</span>
{pkg.original_price > pkg.price && (
<span className="text-sm text-[#8B7D6E] line-through ml-2">{symbol}{Number(pkg.original_price).toFixed(2)}</span>
<span className="text-sm text-[#8B7D6E] line-through ml-2">{fmtCNY(pkg.original_price)}</span>
)}
{pkg.duration > 0 && <p className="text-xs text-[#8B7D6E] mt-1">{t('home.days', { count: pkg.duration })}</p>}
</div>
+6 -3
View File
@@ -8,12 +8,13 @@ import { calcOfficialEquivList } from '../../utils/officialEquiv';
import RotatingEquiv from '../../components/bits/RotatingEquiv';
import CountUp from '../../components/bits/CountUp';
import FadeContent from '../../components/bits/FadeContent';
import ApiEndpoints from '../../components/ApiEndpoints';
export default function CleanHome() {
const { t } = useTranslation();
const { user } = useAuth();
const { site } = useSite();
const { symbol, rate } = useCurrency();
const { symbol, rate, fmtCNY } = useCurrency();
const [models, setModels] = useState([]);
const [packages, setPackages] = useState([]);
@@ -82,6 +83,8 @@ export default function CleanHome() {
</FadeContent>
</section>
<ApiEndpoints />
{/* Features */}
<section className="max-w-5xl mx-auto px-6 py-20">
<FadeContent blur duration={800} delay={100}>
@@ -173,9 +176,9 @@ export default function CleanHome() {
<h3 className="text-base font-semibold text-gray-900">{pkg.name}</h3>
{pkg.description && <p className="text-sm text-gray-500 mt-1">{pkg.description}</p>}
<div className="mt-auto pt-6">
<span className="text-3xl font-bold text-gray-900">{symbol}{Number(pkg.price).toFixed(2)}</span>
<span className="text-3xl font-bold text-gray-900">{fmtCNY(pkg.price)}</span>
{pkg.original_price > pkg.price && (
<span className="text-sm text-gray-500 line-through ml-2">{symbol}{Number(pkg.original_price).toFixed(2)}</span>
<span className="text-sm text-gray-500 line-through ml-2">{fmtCNY(pkg.original_price)}</span>
)}
{pkg.duration > 0 && <p className="text-xs text-gray-500 mt-1">{t('home.days', { count: pkg.duration })}</p>}
</div>
+6 -3
View File
@@ -8,12 +8,13 @@ import { calcOfficialEquivList } from '../../utils/officialEquiv';
import RotatingEquiv from '../../components/bits/RotatingEquiv';
import CountUp from '../../components/bits/CountUp';
import FadeContent from '../../components/bits/FadeContent';
import ApiEndpoints from '../../components/ApiEndpoints';
export default function CorporateHome() {
const { t } = useTranslation();
const { user } = useAuth();
const { site } = useSite();
const { symbol, rate } = useCurrency();
const { symbol, rate, fmtCNY } = useCurrency();
const [models, setModels] = useState([]);
const [packages, setPackages] = useState([]);
@@ -81,6 +82,8 @@ export default function CorporateHome() {
</FadeContent>
</section>
<ApiEndpoints />
{/* Features */}
<section className="bg-slate-50 border-y border-slate-200">
<div className="max-w-6xl mx-auto px-6 lg:px-8 py-20">
@@ -172,9 +175,9 @@ export default function CorporateHome() {
<h3 className="text-base font-semibold text-slate-900">{pkg.name}</h3>
{pkg.description && <p className="text-sm text-slate-500 mt-1">{pkg.description}</p>}
<div className="mt-auto pt-6">
<span className="text-3xl font-bold text-slate-900">{symbol}{Number(pkg.price).toFixed(2)}</span>
<span className="text-3xl font-bold text-slate-900">{fmtCNY(pkg.price)}</span>
{pkg.original_price > pkg.price && (
<span className="text-sm text-slate-500 line-through ml-2">{symbol}{Number(pkg.original_price).toFixed(2)}</span>
<span className="text-sm text-slate-500 line-through ml-2">{fmtCNY(pkg.original_price)}</span>
)}
{pkg.duration > 0 && <p className="text-xs text-slate-500 mt-1">{t('home.days', { count: pkg.duration })}</p>}
</div>
+6 -3
View File
@@ -8,12 +8,13 @@ import { calcOfficialEquivList } from '../../utils/officialEquiv';
import RotatingEquiv from '../../components/bits/RotatingEquiv';
import CountUp from '../../components/bits/CountUp';
import FadeContent from '../../components/bits/FadeContent';
import ApiEndpoints from '../../components/ApiEndpoints';
export default function DarkHome() {
const { t } = useTranslation();
const { user } = useAuth();
const { site } = useSite();
const { symbol, rate } = useCurrency();
const { symbol, rate, fmtCNY } = useCurrency();
const [models, setModels] = useState([]);
const [packages, setPackages] = useState([]);
@@ -86,6 +87,8 @@ export default function DarkHome() {
</FadeContent>
</section>
<ApiEndpoints />
{/* Features */}
<section className="max-w-5xl mx-auto px-6 py-20">
<FadeContent blur duration={800} delay={100}>
@@ -163,9 +166,9 @@ export default function DarkHome() {
<h3 className="text-base font-semibold text-white">{pkg.name}</h3>
{pkg.description && <p className="text-sm text-neutral-500 mt-1">{pkg.description}</p>}
<div className="mt-auto pt-6">
<span className="text-3xl font-bold text-white">{symbol}{Number(pkg.price).toFixed(2)}</span>
<span className="text-3xl font-bold text-white">{fmtCNY(pkg.price)}</span>
{pkg.original_price > pkg.price && (
<span className="text-sm text-neutral-600 line-through ml-2">{symbol}{Number(pkg.original_price).toFixed(2)}</span>
<span className="text-sm text-neutral-600 line-through ml-2">{fmtCNY(pkg.original_price)}</span>
)}
{pkg.duration > 0 && <p className="text-xs text-neutral-600 mt-1">{t('home.days', { count: pkg.duration })}</p>}
</div>
+6 -3
View File
@@ -8,12 +8,13 @@ import { calcOfficialEquivList } from '../../utils/officialEquiv';
import RotatingEquiv from '../../components/bits/RotatingEquiv';
import CountUp from '../../components/bits/CountUp';
import FadeContent from '../../components/bits/FadeContent';
import ApiEndpoints from '../../components/ApiEndpoints';
export default function DefaultHome() {
const { t } = useTranslation();
const { user } = useAuth();
const { site } = useSite();
const { symbol, rate } = useCurrency();
const { symbol, rate, fmtCNY } = useCurrency();
const [models, setModels] = useState([]);
const [packages, setPackages] = useState([]);
@@ -81,6 +82,8 @@ export default function DefaultHome() {
</FadeContent>
</section>
<ApiEndpoints />
{/* Features */}
<section className="max-w-5xl mx-auto px-6 py-20">
<FadeContent blur duration={800} delay={100}>
@@ -172,9 +175,9 @@ export default function DefaultHome() {
<h3 className="text-base font-semibold text-white">{pkg.name}</h3>
{pkg.description && <p className="text-sm text-neutral-500 mt-1">{pkg.description}</p>}
<div className="mt-auto pt-6">
<span className="text-3xl font-bold text-white">{symbol}{Number(pkg.price).toFixed(2)}</span>
<span className="text-3xl font-bold text-white">{fmtCNY(pkg.price)}</span>
{pkg.original_price > pkg.price && (
<span className="text-sm text-neutral-600 line-through ml-2">{symbol}{Number(pkg.original_price).toFixed(2)}</span>
<span className="text-sm text-neutral-600 line-through ml-2">{fmtCNY(pkg.original_price)}</span>
)}
{pkg.duration > 0 && <p className="text-xs text-neutral-600 mt-1">{t('home.days', { count: pkg.duration })}</p>}
</div>
+6 -3
View File
@@ -8,12 +8,13 @@ import { calcOfficialEquivList } from '../../utils/officialEquiv';
import RotatingEquiv from '../../components/bits/RotatingEquiv';
import CountUp from '../../components/bits/CountUp';
import FadeContent from '../../components/bits/FadeContent';
import ApiEndpoints from '../../components/ApiEndpoints';
export default function MinimalHome() {
const { t } = useTranslation();
const { user } = useAuth();
const { site } = useSite();
const { symbol, rate } = useCurrency();
const { symbol, rate, fmtCNY } = useCurrency();
const [models, setModels] = useState([]);
const [packages, setPackages] = useState([]);
@@ -81,6 +82,8 @@ export default function MinimalHome() {
</FadeContent>
</section>
<ApiEndpoints />
{/* Features — two-column layout */}
<section className="max-w-5xl mx-auto px-6 py-20">
<FadeContent blur duration={800} delay={100}>
@@ -155,9 +158,9 @@ export default function MinimalHome() {
<h3 className="text-base font-semibold text-white">{pkg.name}</h3>
{pkg.description && <p className="text-sm text-neutral-500 mt-1">{pkg.description}</p>}
<div className="mt-auto pt-6">
<span className="text-3xl font-bold text-white">{symbol}{Number(pkg.price).toFixed(2)}</span>
<span className="text-3xl font-bold text-white">{fmtCNY(pkg.price)}</span>
{pkg.original_price > pkg.price && (
<span className="text-sm text-neutral-600 line-through ml-2">{symbol}{Number(pkg.original_price).toFixed(2)}</span>
<span className="text-sm text-neutral-600 line-through ml-2">{fmtCNY(pkg.original_price)}</span>
)}
{pkg.duration > 0 && <p className="text-xs text-neutral-600 mt-1">{t('home.days', { count: pkg.duration })}</p>}
</div>
+6 -3
View File
@@ -8,12 +8,13 @@ import { calcOfficialEquivList } from '../../utils/officialEquiv';
import RotatingEquiv from '../../components/bits/RotatingEquiv';
import CountUp from '../../components/bits/CountUp';
import FadeContent from '../../components/bits/FadeContent';
import ApiEndpoints from '../../components/ApiEndpoints';
export default function StarterHome() {
const { t } = useTranslation();
const { user } = useAuth();
const { site } = useSite();
const { symbol, rate } = useCurrency();
const { symbol, rate, fmtCNY } = useCurrency();
const [models, setModels] = useState([]);
const [packages, setPackages] = useState([]);
@@ -87,6 +88,8 @@ export default function StarterHome() {
</FadeContent>
</section>
<ApiEndpoints />
{/* Features */}
<section className="max-w-5xl mx-auto px-6 py-20">
<FadeContent blur duration={800} delay={100}>
@@ -178,9 +181,9 @@ export default function StarterHome() {
<h3 className="text-base font-semibold text-white">{pkg.name}</h3>
{pkg.description && <p className="text-sm text-neutral-500 mt-1">{pkg.description}</p>}
<div className="mt-auto pt-6">
<span className="text-3xl font-bold text-white">{symbol}{Number(pkg.price).toFixed(2)}</span>
<span className="text-3xl font-bold text-white">{fmtCNY(pkg.price)}</span>
{pkg.original_price > pkg.price && (
<span className="text-sm text-neutral-600 line-through ml-2">{symbol}{Number(pkg.original_price).toFixed(2)}</span>
<span className="text-sm text-neutral-600 line-through ml-2">{fmtCNY(pkg.original_price)}</span>
)}
{pkg.duration > 0 && <p className="text-xs text-neutral-600 mt-1">{t('home.days', { count: pkg.duration })}</p>}
</div>
+1
View File
@@ -7,6 +7,7 @@ import { Q } from '../api';
// Patterns must match actual backend model_name format (e.g. "claude-opus-4-5-20251101").
const OFFICIAL_PRICES = [
// Claude — order matters: longer/more-specific first
{ pattern: 'claude-opus-4-7', label: 'Claude Opus 4.7', inputPerMtok: 5, family: 'claudecode' },
{ pattern: 'claude-opus-4-5', label: 'Claude Opus 4.5', inputPerMtok: 5, family: 'claudecode' },
{ pattern: 'claude-opus-4-6', label: 'Claude Opus 4.6', inputPerMtok: 5, family: 'claudecode' },
{ pattern: 'claude-opus-4-1', label: 'Claude Opus 4.1', inputPerMtok: 15, family: 'claudecode' },