Fix dist-site API URL hints and package subscribe UX
This commit is contained in:
1 parent
e6e1a0e45a
commit
4b4f4d3b3c
6 files changed
+241
-75
No files matched your search
+15
-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);
|
||||
@@ -56,7 +65,7 @@ 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 });
|
||||
|
||||
@@ -70,7 +79,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');
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useSite } from '../context/SiteContext';
|
||||
|
||||
const TOOLS = [
|
||||
{ id: 'claudecode', name: 'Claude Code', path: '~/.claude/settings.json' },
|
||||
{ id: 'ccswitch', name: 'CC Switch', path: 'ccswitch://v1/import' },
|
||||
{ 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' },
|
||||
@@ -287,17 +286,39 @@ print(message.content[0].text)`;
|
||||
}
|
||||
};
|
||||
|
||||
const getSelectedToolBaseUrl = () => {
|
||||
if (
|
||||
selectedTool === 'claudecode' ||
|
||||
selectedTool === 'anthropic' ||
|
||||
selectedTool === 'openclaw'
|
||||
) {
|
||||
return `${serverAddress}/`;
|
||||
}
|
||||
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(selectedTool === 'ccswitch' ? 'config.importLinkCopied' : 'config.copied'),
|
||||
);
|
||||
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;
|
||||
@@ -361,6 +382,7 @@ print(message.content[0].text)`;
|
||||
};
|
||||
|
||||
const config = generateConfig();
|
||||
const ccSwitchLink = generateCCSwitchLink();
|
||||
|
||||
if (tokens.length === 0) {
|
||||
return (
|
||||
@@ -481,6 +503,78 @@ print(message.content[0].text)`;
|
||||
</select>
|
||||
</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')}
|
||||
@@ -502,35 +596,69 @@ print(message.content[0].text)`;
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedTool === 'ccswitch' && (
|
||||
<div className="rounded-xl border border-brand-500/20 bg-brand-500/5 px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-page-label">
|
||||
{t('config.selectCCSwitchApp')}
|
||||
</p>
|
||||
<p className="text-xs text-page-muted mt-1">
|
||||
{t('config.ccswitchHint')}
|
||||
</p>
|
||||
</div>
|
||||
<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 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">
|
||||
@@ -586,39 +714,26 @@ print(message.content[0].text)`;
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
{selectedTool === 'ccswitch' ? (
|
||||
<button
|
||||
onClick={handleImportCCSwitch}
|
||||
disabled={!config || launchingCCSwitch}
|
||||
className="px-3 py-1 rounded-lg bg-brand-500 text-white text-xs font-medium hover:bg-brand-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title={t('config.importToCCSwitch')}
|
||||
<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"
|
||||
>
|
||||
{launchingCCSwitch
|
||||
? t('config.launchingCCSwitch')
|
||||
: t('config.importToCCSwitch')}
|
||||
</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>
|
||||
)}
|
||||
<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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -197,6 +197,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 +197,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 计。",
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user