sync: update dist-site from subtree
This commit is contained in:
commit
423911d367
25 files changed
+2320
-232
No files matched your search
Vendored
+2
-2
@@ -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>
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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)",
|
||||
"流式": "流式",
|
||||
"是": "是",
|
||||
"否": "否",
|
||||
"首字时间": "首字时间",
|
||||
"实际扣费来源": "实际扣费来源",
|
||||
"钱包": "钱包",
|
||||
"订阅": "订阅",
|
||||
"分站套餐": "分站套餐",
|
||||
"主站订阅": "主站订阅",
|
||||
"后台订阅": "后台订阅",
|
||||
"计费方式": "计费方式",
|
||||
"订阅抵扣": "订阅抵扣"
|
||||
"订阅抵扣": "订阅抵扣",
|
||||
"定价方式": "定价方式",
|
||||
"价格": "价格",
|
||||
"输入价格": "输入价格",
|
||||
"输出价格": "输出价格",
|
||||
"缓存读取价格": "缓存读取价格",
|
||||
"缓存创建价格": "缓存创建价格"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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'
|
||||
|
||||
@@ -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
@@ -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
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in new issue
Block a user