feat(cloud): add plan comparison and subscription management entry

This commit is contained in:
RockChinQ
2026-10-15 21:29:05 +08:00
parent 338b856eec
commit 978db1701b
16 changed files with 1636 additions and 22 deletions
@@ -0,0 +1,487 @@
import { useEffect, useState, type ReactNode, type ComponentType } from 'react';
import { useTranslation } from 'react-i18next';
import {
ArrowRight,
Check,
Info,
Layers,
Loader2,
RefreshCw,
} from 'lucide-react';
import * as RemixIcons from '@remixicon/react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import type { CurrentWorkspace } from '@/app/infra/entities/workspace';
import {
cloudPlanCheckoutURL,
cloudPlanPlatformIconURL,
cloudSubscriptionManagementURL,
cloudPlanPrice,
cloudPlanCurrencyForLanguage,
fetchPublicCloudPlans,
localizedPlanText,
type CloudPlanBillingCycle,
type PublicCloudPlan,
} from './cloud-plans';
import { supportedPlanPlatforms } from './cloud-plan-platforms';
function PlanIcon({ name }: { name?: string }) {
const key = name
?.replace(/^ri-/, '')
.split('-')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join('');
const Icon =
name && /^ri-[a-z0-9-]+-(line|fill)$/.test(name)
? (
RemixIcons as unknown as Record<
string,
ComponentType<{ className?: string }>
>
)[`Ri${key}`]
: undefined;
return Icon ? (
<Icon className="size-4 shrink-0 text-blue-500" />
) : (
<Check className="size-4 shrink-0 text-blue-500" />
);
}
function Help({ label, children }: { label: string; children: ReactNode }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={label}
className="shrink-0 text-muted-foreground"
>
<Info className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent className="max-w-80 whitespace-pre-line">
{children}
</TooltipContent>
</Tooltip>
);
}
function Benefits({
plan,
spaceURL,
}: {
plan: PublicCloudPlan;
spaceURL: string;
}) {
const { t, i18n } = useTranslation();
const text = (values: Parameters<typeof localizedPlanText>[0]) =>
localizedPlanText(values, i18n.language);
const config = plan.display_config;
const platforms = supportedPlanPlatforms(plan.disabled_adapters);
const quotaLabel = text(config?.quota_summary) || t('cloudPlans.quotas');
const legacyFeatures: Record<string, string> = {
'Multi-user workspace': 'multiUserWorkspace',
'Remote E2B sandbox configuration': 'remoteE2BSandbox',
'Community support': 'communitySupport',
'Everything in Free': 'everythingInFree',
'One always-on global sandbox': 'alwaysOnSandbox',
'Higher workspace limits': 'higherWorkspaceLimits',
};
const items =
config?.items ??
(plan.features ?? []).map((feature) => ({
label: {
en_US: legacyFeatures[feature]
? t(`cloudPlans.features.${legacyFeatures[feature]}`)
: feature,
},
icon: 'ri-check-line',
tooltip: {},
}));
const rows: Record<string, ReactNode> = {
quota_summary: (
<>
<Layers className="size-4 shrink-0 text-blue-500" />
<span className="min-w-0 flex-1">{quotaLabel}</span>
<Help label={quotaLabel}>
{text(config?.tooltips?.quota_summary) && (
<p className="mb-2">{text(config?.tooltips?.quota_summary)}</p>
)}
<ul className="space-y-2">
{Object.entries(plan.limits).map(([key, limit]) => {
const resource = key.replace(/\.max$/, '');
const label =
text(config?.quotas?.[key]?.label) ||
t(`cloudPlans.limits.${resource}`, {
defaultValue: resource.replace(/_/g, ' '),
});
return (
<li key={key}>
<div className="flex justify-between gap-5">
<span>{label}</span>
<span>
{limit < 0
? t('cloudPlans.unlimited')
: limit.toLocaleString()}
</span>
</div>
{text(config?.quotas?.[key]?.tooltip) && (
<p className="mt-1 opacity-75">
{text(config?.quotas?.[key]?.tooltip)}
</p>
)}
</li>
);
})}
</ul>
</Help>
</>
),
retention: (
<>
<PlanIcon name="ri-history-line" />
<span className="min-w-0 flex-1">
{t('cloudPlans.retention', {
count: plan.chat_history_retention_days,
})}
</span>
<Help label={t('cloudPlans.retentionLabel')}>
{text(config?.tooltips?.retention) || t('cloudPlans.retentionHelp')}
</Help>
</>
),
monthly_credits: (
<>
<PlanIcon name="ri-coin-line" />
<span className="min-w-0 flex-1">
{t('cloudPlans.credits', { count: plan.monthly_bonus_credits })}
</span>
<Help label={t('cloudPlans.creditsLabel')}>
{text(config?.tooltips?.monthly_credits) ||
t('cloudPlans.creditsHelp')}
</Help>
</>
),
supported_platforms: (
<>
<PlanIcon name="ri-apps-2-line" />
<span className="min-w-0 flex-1">{t('cloudPlans.platforms')}</span>
<Tooltip>
<TooltipTrigger
type="button"
aria-label={t('cloudPlans.platforms')}
className="flex h-5 shrink-0 items-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-2 focus-visible:outline-primary"
onPointerDown={(event) => {
if (event.pointerType === 'touch') event.preventDefault();
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
<Info className="size-3.5" aria-hidden />
</TooltipTrigger>
<TooltipContent
className="max-w-[min(360px,calc(100vw-2rem))] bg-foreground p-3 text-background"
arrowClassName="bg-foreground fill-foreground"
>
{text(config?.tooltips?.supported_platforms) && (
<p className="mb-2 whitespace-pre-line break-words">
{text(config?.tooltips?.supported_platforms)}
</p>
)}
<div className="flex flex-wrap gap-1.5" data-supported-platforms>
{platforms.map((platform) => (
<span
key={platform.name}
className="inline-flex items-center gap-1.5 rounded border border-background/30 px-1.5 py-1 text-xs"
>
<img
src={cloudPlanPlatformIconURL(spaceURL, platform.icon)}
alt=""
referrerPolicy="no-referrer"
className="size-4 shrink-0 rounded-sm bg-white p-0.5 object-contain"
/>
{t(
`cloudPlans.platformNames.${platform.name.replace('LINE', 'line').replace('openclaw-weixin', 'openclaw_weixin')}`,
{ defaultValue: platform.label },
)}
</span>
))}
</div>
{platforms.length === 0 && <p>{t('common.none')}</p>}
</TooltipContent>
</Tooltip>
</>
),
};
items.forEach((item, index) => {
const label = text(item.label);
rows[`item:${index}`] = (
<>
<PlanIcon name={item.icon} />
<span className="min-w-0 flex-1 whitespace-pre-line">{label}</span>
{text(item.tooltip) && <Help label={label}>{text(item.tooltip)}</Help>}
</>
);
});
const order = [
...new Set([
...(config?.order ?? []),
'quota_summary',
'supported_platforms',
'retention',
'monthly_credits',
...items.map((_, index) => `item:${index}`),
]),
].filter((key) => key in rows);
return (
<ul className="space-y-3 text-sm">
{order.map((key) => (
<li key={key} className="flex items-start gap-2 break-words">
{rows[key]}
</li>
))}
</ul>
);
}
export default function CloudPlansDialog({
open,
onOpenChange,
workspace,
spaceURL,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
workspace: CurrentWorkspace;
spaceURL: string;
}) {
const { t, i18n } = useTranslation();
const [plans, setPlans] = useState<PublicCloudPlan[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [attempt, setAttempt] = useState(0);
const [cycle, setCycle] = useState<CloudPlanBillingCycle>('annual');
const currency = cloudPlanCurrencyForLanguage(i18n.language);
const canManage = ['owner', 'admin'].includes(workspace.membership.role);
useEffect(() => {
if (!open) return;
const controller = new AbortController();
let active = true;
const timeout = window.setTimeout(() => controller.abort(), 15000);
setLoading(true);
setError(false);
fetchPublicCloudPlans(spaceURL, controller.signal)
.then((value) => {
if (active) setPlans(value);
})
.catch(() => {
if (active) setError(true);
})
.finally(() => {
window.clearTimeout(timeout);
if (active) setLoading(false);
});
return () => {
active = false;
controller.abort();
window.clearTimeout(timeout);
};
}, [open, spaceURL, attempt]);
const formatPrice = (price: number) =>
new Intl.NumberFormat(i18n.language.replace('_', '-'), {
style: 'currency',
currency,
maximumFractionDigits: currency === 'JPY' ? 0 : 2,
}).format(price);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="flex max-h-[90dvh] flex-col gap-5 overflow-hidden sm:max-w-5xl"
data-cloud-plans
>
<DialogHeader>
<DialogTitle>{t('cloudPlans.title')}</DialogTitle>
<DialogDescription>
{t('cloudPlans.description', {
workspace: workspace.workspace.name,
})}
</DialogDescription>
</DialogHeader>
<div className="flex flex-wrap items-center justify-between gap-3">
<div
className="inline-flex rounded-lg bg-muted p-1"
role="group"
aria-label={t('cloudPlans.billing')}
>
{(['monthly', 'annual'] as const).map((value) => (
<Button
key={value}
size="sm"
variant="ghost"
aria-pressed={cycle === value}
className={cn(cycle === value && 'bg-background shadow-sm')}
onClick={() => setCycle(value)}
>
{t(`cloudPlans.${value}`)}
</Button>
))}
</div>
</div>
<div className="min-h-0 overflow-y-auto [scrollbar-gutter:stable]">
{loading ? (
<div
className="flex min-h-48 items-center justify-center"
role="status"
>
<Loader2 className="size-6 animate-spin" />
<span className="sr-only">{t('cloudPlans.loading')}</span>
</div>
) : error ? (
<div className="space-y-3 py-10 text-center" role="alert">
<p>{t('cloudPlans.error')}</p>
<Button
variant="outline"
onClick={() => setAttempt((value) => value + 1)}
>
<RefreshCw />
{t('cloudPlans.retry')}
</Button>
</div>
) : !plans.length ? (
<p className="py-10 text-center text-muted-foreground">
{t('cloudPlans.empty')}
</p>
) : (
<TooltipProvider delayDuration={200}>
<div
className={cn(
'grid gap-4 p-1 sm:grid-cols-2',
plans.length > 2 && 'lg:grid-cols-3',
)}
>
{plans.map((plan) => {
const current = workspace.plan_name === plan.name;
const downgradeBlocked =
Boolean(
workspace.plan_name && workspace.plan_name !== 'free',
) && plan.name === 'free';
const price = cloudPlanPrice(plan, cycle, currency);
const name =
localizedPlanText(plan.display_name, i18n.language) ||
plan.name.toUpperCase();
return (
<article
key={plan.uuid}
className={cn(
'flex min-w-0 flex-col rounded-xl border p-5',
current && 'border-blue-500/50 bg-blue-500/5',
)}
aria-label={name}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="flex items-center gap-2 font-semibold">
<PlanIcon name={plan.display_config?.icon} />
{name}
</h3>
{current ? (
<Badge variant="secondary">
{t('cloudPlans.current')}
</Badge>
) : plan.highlight ? (
<Badge>{t('cloudPlans.recommended')}</Badge>
) : null}
</div>
<p className="mt-2 text-sm text-muted-foreground">
{localizedPlanText(plan.description, i18n.language)}
</p>
<p className="mt-5 text-2xl font-semibold">
{price === 0
? t('cloudPlans.free')
: formatPrice(price)}
{price > 0 && (
<span className="ml-1 text-xs font-normal text-muted-foreground">
/ {t(`cloudPlans.${cycle}`)}
</span>
)}
</p>
<p className="mb-5 mt-1 min-h-4 text-xs text-muted-foreground">
{cycle === 'annual' && price > 0
? t('cloudPlans.annualNote', {
price: formatPrice(price / 12),
})
: ''}
</p>
<div className="flex-1">
<Benefits plan={plan} spaceURL={spaceURL} />
</div>
{downgradeBlocked ? null : !canManage ? (
<Button
className="mt-6 w-full"
variant="outline"
disabled
>
{t('cloudPlans.ownerOnly')}
</Button>
) : (
<Button
asChild
className="mt-6 w-full"
variant={plan.highlight ? 'default' : 'outline'}
>
<a
href={
current
? cloudSubscriptionManagementURL(
spaceURL,
workspace.workspace.uuid,
)
: cloudPlanCheckoutURL(
spaceURL,
workspace.workspace.uuid,
plan.name,
cycle,
currency,
)
}
target="_blank"
rel="noopener noreferrer"
>
{t(
current
? 'cloudPlans.manageSubscription'
: 'cloudPlans.choose',
)}
<ArrowRight className="size-4" />
</a>
</Button>
)}
</article>
);
})}
</div>
</TooltipProvider>
)}
</div>
<p className="text-xs text-muted-foreground">
{t('cloudPlans.handoff')}
</p>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,28 @@
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
export default function WorkspacePlanBadge({
planName,
}: {
planName?: string | null;
}) {
const { t } = useTranslation();
const isPaidPlan =
Boolean(planName?.trim()) && planName?.trim().toLowerCase() !== 'free';
return (
<Badge
variant="outline"
className={cn(
'text-[11px] uppercase',
isPaidPlan
? 'border-blue-200 bg-blue-50 font-semibold text-blue-700 dark:border-blue-400/30 dark:bg-blue-500/15 dark:text-blue-300'
: 'bg-muted text-muted-foreground',
)}
>
{planName || t('workspace.planUnavailable')}
</Badge>
);
}
@@ -1,7 +1,14 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
lazy,
Suspense,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import {
Copy,
ExternalLink,
ArrowRight,
Loader2,
Trash2,
UserPlus,
@@ -39,6 +46,9 @@ import {
PanelBody,
PanelToolbar,
} from '@/app/home/components/settings-dialog/panel-layout';
import WorkspacePlanBadge from './WorkspacePlanBadge';
const CloudPlansDialog = lazy(() => import('./CloudPlansDialog'));
interface WorkspaceSettingsPanelProps {
active: boolean;
@@ -66,6 +76,7 @@ export default function WorkspaceSettingsPanel({
active,
}: WorkspaceSettingsPanelProps) {
const { t } = useTranslation();
const [plansOpen, setPlansOpen] = useState(false);
const [workspaceInfo, setWorkspaceInfo] = useState<CurrentWorkspace | null>(
null,
);
@@ -93,9 +104,6 @@ export default function WorkspaceSettingsPanel({
const canInvite = permissions.has('member.invite');
const canUpdateMembers = permissions.has('member.update_role');
const canRemoveMembers = permissions.has('member.remove');
const cloudPortalURL = workspaceInfo
? `${systemInfo.cloud_service_url.replace(/\/$/, '')}/cloud?workspace=${encodeURIComponent(workspaceInfo.workspace.uuid)}&step=plan`
: '';
const loadWorkspace = useCallback(async () => {
setLoading(true);
@@ -254,19 +262,15 @@ export default function WorkspaceSettingsPanel({
<p className="text-[11px] text-muted-foreground">
{t('workspace.currentPlan')}
</p>
<p className="text-sm font-semibold uppercase">
{workspaceInfo.plan_name || t('workspace.planUnavailable')}
</p>
<WorkspacePlanBadge planName={workspaceInfo.plan_name} />
</div>
<Button asChild size="sm" className="shrink-0">
<a
href={cloudPortalURL}
target="_blank"
rel="noopener noreferrer"
>
{t('workspace.upgradePlan')}
<ExternalLink className="size-3.5" />
</a>
<Button
size="sm"
className="shrink-0"
onClick={() => setPlansOpen(true)}
>
{t('cloudPlans.title')}
<ArrowRight className="size-3.5" />
</Button>
</div>
)}
@@ -490,6 +494,17 @@ export default function WorkspaceSettingsPanel({
</section>
)}
</PanelBody>
{isCloudProjection && workspaceInfo && plansOpen && (
<Suspense fallback={null}>
<CloudPlansDialog
key={workspaceInfo.workspace.uuid}
open={plansOpen}
onOpenChange={setPlansOpen}
workspace={workspaceInfo}
spaceURL={systemInfo.cloud_service_url}
/>
</Suspense>
)}
</>
);
}
@@ -15,6 +15,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import WorkspacePlanBadge from './WorkspacePlanBadge';
export const OPEN_WORKSPACE_SETTINGS_EVENT = 'langbot:open-workspace-settings';
@@ -70,9 +71,7 @@ export default function WorkspaceSwitcher({
{entry.workspace.name}
</span>
{entry.workspace.source === 'cloud_projection' && (
<span className="rounded-md border bg-muted px-2 py-0.5 text-[11px] font-medium uppercase text-muted-foreground">
{entry.plan_name || t('workspace.planUnavailable')}
</span>
<WorkspacePlanBadge planName={entry.plan_name} />
)}
{selected && (
<Button
@@ -0,0 +1,201 @@
// Keep this public catalog aligned with Space web/lib/platform-adapters.ts.
// Do not use the workspace-filtered adapter API to describe other plans.
export const KNOWN_ADAPTERS = [
{
name: 'LINE',
label: 'LINE',
icon: '/platforms/line.png',
},
{
name: 'aiocqhttp',
label: 'OneBot v11',
icon: '/platforms/aiocqhttp.svg',
},
{
name: 'aiocqhttp-omni',
label: 'OneBot v11 (Omni)',
icon: '/platforms/aiocqhttp.svg',
},
{
name: 'dingtalk',
label: 'DingTalk',
icon: '/platforms/dingtalk.svg',
},
{
name: 'dingtalk-omni',
label: 'DingTalk (Omni)',
icon: '/platforms/dingtalk.svg',
},
{
name: 'discord',
label: 'Discord',
icon: '/platforms/discord.svg',
},
{
name: 'discord-omni',
label: 'Discord (Omni)',
icon: '/platforms/discord.svg',
},
{
name: 'http_bot',
label: 'HTTP Bot',
icon: '/platforms/http_bot.svg',
},
{
name: 'kook',
label: 'KOOK',
icon: '/platforms/kook.png',
},
{
name: 'kook-omni',
label: 'KOOK (Omni)',
icon: '/platforms/kook.png',
},
{
name: 'lark',
label: 'Lark',
icon: '/platforms/lark.svg',
},
{
name: 'lark-omni',
label: 'Lark / Feishu (Omni)',
icon: '/platforms/lark.svg',
},
{
name: 'matrix',
label: 'Matrix',
icon: '/platforms/matrix.png',
},
{
name: 'mattermost',
label: 'Mattermost',
icon: '/platforms/mattermost.svg',
},
{
name: 'officialaccount',
label: 'Official Account',
icon: '/platforms/officialaccount.png',
},
{
name: 'officialaccount-omni',
label: 'Official Account (Omni)',
icon: '/platforms/officialaccount.png',
},
{
name: 'openclaw-weixin',
label: 'OpenClaw WeChat',
icon: '/platforms/openclaw_weixin.png',
},
{
name: 'qqofficial',
label: 'QQ Official API',
icon: '/platforms/qqofficial.svg',
},
{
name: 'qqofficial-omni',
label: 'QQ Official API (Omni)',
icon: '/platforms/qqofficial.svg',
},
{
name: 'satori',
label: 'Satori',
icon: '/platforms/satori.png',
},
{
name: 'slack',
label: 'Slack',
icon: '/platforms/slack.png',
},
{
name: 'slack-omni',
label: 'Slack (Omni)',
icon: '/platforms/slack.png',
},
{
name: 'telegram',
label: 'Telegram',
icon: '/platforms/telegram.svg',
},
{
name: 'telegram-omni',
label: 'Telegram (Omni)',
icon: '/platforms/telegram.svg',
},
{
name: 'web_page_bot',
label: 'Page Bot',
icon: '/platforms/webpage.webp',
},
{
name: 'wechatpad',
label: 'WeChatPad',
icon: '/platforms/wechatpad.png',
},
{
name: 'wecom',
label: 'WeCom',
icon: '/platforms/wecom.png',
},
{
name: 'wecom-omni',
label: 'WeCom (Omni)',
icon: '/platforms/wecom.png',
},
{
name: 'wecombot',
label: 'WeComBot',
icon: '/platforms/wecombot.png',
},
{
name: 'wecombot-omni',
label: 'WeComBot (Omni)',
icon: '/platforms/wecombot.png',
},
{
name: 'wecomcs',
label: 'WeComCustomerService',
icon: '/platforms/wecomcs.png',
},
{
name: 'wecomcs-omni',
label: 'WeCom Customer Service (Omni)',
icon: '/platforms/wecomcs.png',
},
];
export function supportedPlanAdapters(disabled: string[] = []) {
const blocked = new Set(
disabled.map((name) =>
name === 'line'
? 'LINE'
: name.endsWith('-eba')
? name.slice(0, -4) + '-omni'
: name,
),
);
return KNOWN_ADAPTERS.filter(
(adapter) =>
!blocked.has(adapter.name) &&
!(
adapter.name.endsWith('-omni') && blocked.has(adapter.name.slice(0, -5))
),
);
}
/** Collapse transport variants into one platform logo after applying restrictions. */
export function supportedPlanPlatforms(disabled: string[] = []) {
const platforms = new Map<
string,
{ name: string; label: string; icon: string }
>();
for (const adapter of supportedPlanAdapters(disabled)) {
const name = adapter.name.replace(/-omni$/, '');
if (!platforms.has(name))
platforms.set(name, {
...adapter,
name,
label: adapter.label.replace(/ \(Omni\)$/, ''),
});
}
return [...platforms.values()];
}
@@ -0,0 +1,159 @@
export type CloudPlanBillingCycle = 'monthly' | 'annual';
export type CloudPlanCurrency = 'CNY' | 'USD' | 'JPY';
export type PlanLocalizedText = Partial<Record<string, string>>;
export interface PublicCloudPlan {
uuid: string;
name: string;
revision: number;
display_name: PlanLocalizedText;
description: PlanLocalizedText;
price_cny: string;
price_usd: string;
price_jpy: string;
limits: Record<string, number>;
features: string[];
is_active: boolean;
sort_order: number;
highlight: boolean;
chat_history_retention_days: number;
monthly_bonus_credits: number;
disabled_adapters: string[];
display_config?: {
icon?: string;
order?: string[];
quota_summary?: PlanLocalizedText;
tooltips?: Partial<Record<string, PlanLocalizedText>>;
quotas?: Record<
string,
{ label?: PlanLocalizedText; tooltip?: PlanLocalizedText }
>;
items?: Array<{
label: PlanLocalizedText;
tooltip?: PlanLocalizedText;
icon: string;
}>;
} | null;
}
export function localizedPlanText(
values: PlanLocalizedText | undefined,
language: string,
): string {
if (!values) return '';
const locale = language.replace(/-/g, '_');
const key = Object.keys(values).find(
(key) => key.toLowerCase() === locale.toLowerCase(),
);
return (
(key && values[key]) ||
values.en_US ||
values.zh_Hans ||
Object.values(values).find(Boolean) ||
''
);
}
export function cloudPlanCurrencyForLanguage(
language: string,
): CloudPlanCurrency {
const locale = language.replace(/_/g, '-').toLowerCase();
if (locale === 'zh-hans' || locale.startsWith('zh-hans-')) return 'CNY';
if (locale === 'ja' || locale.startsWith('ja-')) return 'JPY';
return 'USD';
}
export function cloudPlanPrice(
plan: PublicCloudPlan,
cycle: CloudPlanBillingCycle,
currency: CloudPlanCurrency,
): number {
const amount = Number(
plan[
currency === 'CNY'
? 'price_cny'
: currency === 'JPY'
? 'price_jpy'
: 'price_usd'
],
);
if (!Number.isFinite(amount) || amount < 0)
throw new Error('Invalid public plan price');
return amount * (cycle === 'annual' ? 10 : 1);
}
function spaceURL(base: string, path: string): URL {
const url = new URL(base);
if (
!['http:', 'https:'].includes(url.protocol) ||
url.username ||
url.password
)
throw new Error('Invalid Space URL');
url.pathname = `${url.pathname.replace(/\/$/, '')}${path}`;
url.search = '';
url.hash = '';
return url;
}
export function cloudPlanCheckoutURL(
base: string,
workspace: string,
plan: string,
billing: CloudPlanBillingCycle,
currency: CloudPlanCurrency,
): string {
const url = spaceURL(base, '/cloud');
url.search = new URLSearchParams({
environment: 'beta',
step: 'plan',
workspace,
plan,
billing,
currency,
}).toString();
return url.toString();
}
/** Workspace navigation only; Space authenticates and authorizes independently. */
export function cloudSubscriptionManagementURL(
base: string,
workspace: string,
): string {
const url = spaceURL(base, '/cloud');
url.search = new URLSearchParams({
environment: 'beta',
step: 'subscription',
workspace,
}).toString();
return url.toString();
}
export function cloudPlanPlatformIconURL(base: string, icon: string): string {
return spaceURL(base, icon).toString();
}
/** This request is deliberately independent of Core and Space authenticated clients. */
export async function fetchPublicCloudPlans(
base: string,
signal: AbortSignal,
): Promise<PublicCloudPlan[]> {
const response = await fetch(spaceURL(base, '/api/v1/cloud/v2/plans'), {
credentials: 'omit',
referrerPolicy: 'no-referrer',
signal,
});
if (!response.ok) throw new Error('Could not load public plans');
const result = await response.json();
if (result.code !== 0 || !Array.isArray(result.data))
throw new Error('Invalid plan catalog');
const plans = (result.data as PublicCloudPlan[]).filter(
(plan) => plan.is_active,
);
for (const plan of plans) {
if (!plan.uuid || !plan.name || !plan.limits)
throw new Error('Invalid public plan');
for (const currency of ['CNY', 'USD', 'JPY'] as const)
cloudPlanPrice(plan, 'monthly', currency);
}
return plans.sort((a, b) => a.sort_order - b.sort_order);
}
+10 -2
View File
@@ -109,10 +109,13 @@ function TooltipTrigger({
function TooltipContent({
className,
arrowClassName,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
}: React.ComponentProps<typeof TooltipPrimitive.Content> & {
arrowClassName?: string;
}) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
@@ -125,7 +128,12 @@ function TooltipContent({
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
<TooltipPrimitive.Arrow
className={cn(
'bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]',
arrowClassName,
)}
/>
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
+69
View File
@@ -1,6 +1,75 @@
import slackSetup from './slack-setup/en-US';
import pipelineMigration from './pipeline-migration/en-US';
const enUS = {
cloudPlans: {
platformNames: {
qqofficial: 'QQ Official API',
dingtalk: 'DingTalk',
discord: 'Discord',
telegram: 'Telegram',
lark: 'Lark',
slack: 'Slack',
kook: 'KOOK',
line: 'LINE',
wecom: 'WeCom',
wecombot: 'WeComBot',
wecomcs: 'WeComCustomerService',
officialaccount: 'Official Account',
wechatpad: 'WeChatPad',
openclaw_weixin: 'OpenClaw WeChat',
aiocqhttp: 'OneBot v11',
satori: 'Satori',
},
manageSubscription: 'Manage subscription',
downgradeUnavailable: 'Cannot downgrade to Free',
features: {
multiUserWorkspace: 'Multi-user workspace',
remoteE2BSandbox: 'Remote E2B sandbox configuration',
communitySupport: 'Community support',
everythingInFree: 'Everything in Free',
alwaysOnSandbox: 'One always-on global sandbox',
higherWorkspaceLimits: 'Higher workspace limits',
},
title: 'Compare plans',
description: 'Choose a plan for {{workspace}}',
billing: 'Billing cycle',
monthly: 'Monthly',
annual: 'Yearly',
currency: 'Currency',
loading: 'Loading plans…',
error: 'Could not load plans',
retry: 'Retry',
empty: 'No plans available',
current: 'Current plan',
recommended: 'Recommended',
free: 'Free',
annualNote: 'Equivalent to {{price}} / month',
ownerOnly: 'Owner or admin only',
choose: 'Continue to payment',
handoff:
'Continue in Space to sign in and pay. Your selection will be preserved.',
quotas: 'Workspace quotas',
unlimited: 'Unlimited',
retention: 'Message history · {{count}} days',
retentionLabel: 'Message history retention',
retentionHelp:
'Message history is automatically removed after this period.',
credits: '{{count}} credits every month',
creditsLabel: 'Monthly credits',
creditsHelp:
'Credits are granted each subscription month, including on yearly plans.',
platforms: 'Mainstream platforms supported',
excludedPlatforms: 'Excluded: {{platforms}}',
allPlatforms: 'All currently available platforms',
limits: {
bots: 'Bots',
pipelines: 'Processors',
knowledge_bases: 'Knowledge bases',
plugins: 'Extensions',
members: 'Members',
managed_sandbox_sessions: 'Managed sandbox sessions',
},
},
workspaceLanguage: {
label: 'Workspace language',
description:
+68
View File
@@ -1,5 +1,73 @@
import pipelineMigration from './pipeline-migration/es-ES';
const esES = {
cloudPlans: {
platformNames: {
qqofficial: 'QQ Official API',
dingtalk: 'DingTalk',
discord: 'Discord',
telegram: 'Telegram',
lark: 'Lark',
slack: 'Slack',
kook: 'KOOK',
line: 'LINE',
wecom: 'WeCom',
wecombot: 'WeComBot',
wecomcs: 'WeComCustomerService',
officialaccount: 'Cuenta Oficial',
wechatpad: 'WeChatPad',
openclaw_weixin: 'OpenClaw WeChat',
aiocqhttp: 'OneBot v11',
satori: 'Satori',
},
manageSubscription: 'Gestionar suscripción',
downgradeUnavailable: 'No se puede cambiar al plan gratuito',
features: {
multiUserWorkspace: 'Espacio de trabajo multiusuario',
remoteE2BSandbox: 'Configuración de sandbox E2B remoto',
communitySupport: 'Soporte de la comunidad',
everythingInFree: 'Todas las funciones del plan gratuito',
alwaysOnSandbox: 'Un sandbox global siempre activo',
higherWorkspaceLimits: 'Límites superiores del espacio de trabajo',
},
title: 'Comparar planes',
description: 'Elige un plan para {{workspace}}',
billing: 'Ciclo de facturación',
monthly: 'Mensual',
annual: 'Anual',
currency: 'Moneda',
loading: 'Cargando planes…',
error: 'No se pudieron cargar los planes',
retry: 'Reintentar',
empty: 'No hay planes disponibles',
current: 'Plan actual',
recommended: 'Recomendado',
free: 'Gratis',
annualNote: 'Equivale a {{price}} / mes',
ownerOnly: 'Solo propietario o administrador',
choose: 'Continuar al pago',
handoff: 'Inicia sesión y paga en Space. Se conservará tu selección.',
quotas: 'Cuotas del espacio',
unlimited: 'Sin límite',
retention: 'Historial · {{count}} días',
retentionLabel: 'Retención del historial',
retentionHelp:
'El historial se elimina automáticamente al superar este periodo.',
credits: '{{count}} créditos al mes',
creditsLabel: 'Créditos mensuales',
creditsHelp:
'Los créditos se conceden cada mes de suscripción, también en los planes anuales.',
platforms: 'Plataformas principales compatibles',
excludedPlatforms: 'No compatibles: {{platforms}}',
allPlatforms: 'Todas las plataformas disponibles',
limits: {
bots: 'Bots',
pipelines: 'Procesadores',
knowledge_bases: 'Bases de conocimiento',
plugins: 'Extensiones',
members: 'Miembros',
managed_sandbox_sessions: 'Sesiones de sandbox gestionado',
},
},
workspaceLanguage: {
label: 'Idioma del espacio de trabajo',
description:
+66
View File
@@ -1,6 +1,72 @@
import slackSetup from './slack-setup/ja-JP';
import pipelineMigration from './pipeline-migration/ja-JP';
const jaJP = {
cloudPlans: {
platformNames: {
qqofficial: 'QQ公式API',
dingtalk: 'DingTalk',
discord: 'Discord',
telegram: 'Telegram',
lark: 'Lark',
slack: 'Slack',
kook: 'KOOK',
line: 'LINE',
wecom: 'WeCom',
wecombot: 'WeComBot',
wecomcs: 'WeComカスタマーサービス',
officialaccount: 'WeChat公式アカウント',
wechatpad: 'WeChatPad',
openclaw_weixin: 'OpenClaw WeChat',
aiocqhttp: 'OneBot v11',
satori: 'Satori',
},
manageSubscription: 'サブスクリプションを管理',
downgradeUnavailable: '無料プランへの変更はできません',
features: {
multiUserWorkspace: '複数ユーザーのワークスペース',
remoteE2BSandbox: 'リモート E2B サンドボックスの設定',
communitySupport: 'コミュニティサポート',
everythingInFree: '無料プランのすべての機能',
alwaysOnSandbox: '常時稼働のグローバルサンドボックス 1 個',
higherWorkspaceLimits: 'ワークスペースの上限を拡大',
},
title: 'プランを比較',
description: '{{workspace}} のプランを選択',
billing: '請求周期',
monthly: '月払い',
annual: '年払い',
currency: '通貨',
loading: 'プランを読み込み中…',
error: 'プランを読み込めませんでした',
retry: '再試行',
empty: '利用可能なプランはありません',
current: '現在のプラン',
recommended: 'おすすめ',
free: '無料',
annualNote: '月額 {{price}} 相当',
ownerOnly: '所有者または管理者のみ',
choose: '支払いに進む',
handoff: 'Space でログインしてお支払いください。選択内容は保持されます。',
quotas: 'ワークスペースの上限',
unlimited: '無制限',
retention: 'メッセージ履歴 · {{count}} 日間',
retentionLabel: 'メッセージ履歴の保存期間',
retentionHelp: '保存期間を過ぎた履歴は自動的に削除されます。',
credits: '毎月 {{count}} クレジット',
creditsLabel: '毎月のクレジット',
creditsHelp: '年払いでも、契約の各月にクレジットが付与されます。',
platforms: '主要プラットフォームに対応',
excludedPlatforms: '対象外:{{platforms}}',
allPlatforms: '現在利用可能なすべてのプラットフォーム',
limits: {
bots: 'ボット',
pipelines: 'プロセッサー',
knowledge_bases: 'ナレッジベース',
plugins: '拡張機能',
members: 'メンバー',
managed_sandbox_sessions: '管理サンドボックスセッション',
},
},
workspaceLanguage: {
label: 'ワークスペースの言語',
description:
+68
View File
@@ -1,5 +1,73 @@
import pipelineMigration from './pipeline-migration/ru-RU';
const ruRU = {
cloudPlans: {
platformNames: {
qqofficial: 'QQ Official API',
dingtalk: 'DingTalk',
discord: 'Discord',
telegram: 'Telegram',
lark: 'Lark',
slack: 'Slack',
kook: 'KOOK',
line: 'LINE',
wecom: 'WeCom',
wecombot: 'WeComBot',
wecomcs: 'WeComCustomerService',
officialaccount: 'Official Account',
wechatpad: 'WeChatPad',
openclaw_weixin: 'OpenClaw WeChat',
aiocqhttp: 'OneBot v11',
satori: 'Satori',
},
manageSubscription: 'Управление подпиской',
downgradeUnavailable: 'Переход на бесплатный план недоступен',
features: {
multiUserWorkspace: 'Многопользовательское рабочее пространство',
remoteE2BSandbox: 'Настройка удалённой песочницы E2B',
communitySupport: 'Поддержка сообщества',
everythingInFree: 'Все возможности бесплатного плана',
alwaysOnSandbox: 'Одна постоянно работающая глобальная песочница',
higherWorkspaceLimits: 'Повышенные лимиты рабочего пространства',
},
title: 'Сравнить планы',
description: 'Выберите план для {{workspace}}',
billing: 'Период оплаты',
monthly: 'За месяц',
annual: 'За год',
currency: 'Валюта',
loading: 'Загрузка планов…',
error: 'Не удалось загрузить планы',
retry: 'Повторить',
empty: 'Нет доступных планов',
current: 'Текущий план',
recommended: 'Рекомендуем',
free: 'Бесплатно',
annualNote: 'Эквивалент {{price}} / месяц',
ownerOnly: 'Только владелец или администратор',
choose: 'Перейти к оплате',
handoff: 'Войдите в Space и оплатите. Ваш выбор будет сохранён.',
quotas: 'Лимиты рабочего пространства',
unlimited: 'Без ограничений',
retention: 'История сообщений · {{count}} дней',
retentionLabel: 'Срок хранения сообщений',
retentionHelp:
'По истечении этого срока сообщения автоматически удаляются.',
credits: '{{count}} кредитов в месяц',
creditsLabel: 'Ежемесячные кредиты',
creditsHelp:
'Кредиты начисляются каждый месяц подписки, в том числе при годовой оплате.',
platforms: 'Поддержка популярных платформ',
excludedPlatforms: 'Недоступны: {{platforms}}',
allPlatforms: 'Все доступные платформы',
limits: {
bots: 'Боты',
pipelines: 'Обработчики',
knowledge_bases: 'Базы знаний',
plugins: 'Расширения',
members: 'Участники',
managed_sandbox_sessions: 'Управляемые сеансы песочницы',
},
},
workspaceLanguage: {
label: 'Язык рабочего пространства',
description:
+66
View File
@@ -1,5 +1,71 @@
import pipelineMigration from './pipeline-migration/th-TH';
const thTH = {
cloudPlans: {
platformNames: {
qqofficial: 'QQ Official API',
dingtalk: 'DingTalk',
discord: 'Discord',
telegram: 'Telegram',
lark: 'Lark',
slack: 'Slack',
kook: 'KOOK',
line: 'LINE',
wecom: 'WeCom',
wecombot: 'WeComBot',
wecomcs: 'WeComCustomerService',
officialaccount: 'Official Account',
wechatpad: 'WeChatPad',
openclaw_weixin: 'OpenClaw WeChat',
aiocqhttp: 'OneBot v11',
satori: 'Satori',
},
manageSubscription: 'จัดการการสมัครสมาชิก',
downgradeUnavailable: 'ไม่สามารถลดระดับเป็นแผนฟรีได้',
features: {
multiUserWorkspace: 'พื้นที่ทำงานสำหรับผู้ใช้หลายคน',
remoteE2BSandbox: 'การตั้งค่าแซนด์บ็อกซ์ E2B ระยะไกล',
communitySupport: 'การสนับสนุนจากชุมชน',
everythingInFree: 'ฟีเจอร์ทั้งหมดในแผนฟรี',
alwaysOnSandbox: 'แซนด์บ็อกซ์ส่วนกลางที่ทำงานตลอดเวลา 1 รายการ',
higherWorkspaceLimits: 'โควตาพื้นที่ทำงานที่สูงขึ้น',
},
title: 'เปรียบเทียบแผน',
description: 'เลือกแผนสำหรับ {{workspace}}',
billing: 'รอบการเรียกเก็บเงิน',
monthly: 'รายเดือน',
annual: 'รายปี',
currency: 'สกุลเงิน',
loading: 'กำลังโหลดแผน…',
error: 'ไม่สามารถโหลดแผนได้',
retry: 'ลองอีกครั้ง',
empty: 'ไม่มีแผนที่ใช้ได้',
current: 'แผนปัจจุบัน',
recommended: 'แนะนำ',
free: 'ฟรี',
annualNote: 'เทียบเท่า {{price}} / เดือน',
ownerOnly: 'เฉพาะเจ้าของหรือผู้ดูแลระบบ',
choose: 'ดำเนินการชำระเงิน',
handoff: 'เข้าสู่ระบบและชำระเงินใน Space โดยจะเก็บตัวเลือกของคุณไว้',
quotas: 'โควตาพื้นที่ทำงาน',
unlimited: 'ไม่จำกัด',
retention: 'ประวัติข้อความ · {{count}} วัน',
retentionLabel: 'ระยะเวลาเก็บประวัติข้อความ',
retentionHelp: 'ประวัติข้อความจะถูกลบโดยอัตโนมัติเมื่อเกินระยะเวลานี้',
credits: '{{count}} เครดิตต่อเดือน',
creditsLabel: 'เครดิตรายเดือน',
creditsHelp: 'ได้รับเครดิตทุกเดือนของการสมัคร รวมถึงแผนรายปี',
platforms: 'รองรับแพลตฟอร์มหลัก',
excludedPlatforms: 'ไม่รองรับ: {{platforms}}',
allPlatforms: 'ทุกแพลตฟอร์มที่พร้อมใช้งาน',
limits: {
bots: 'บอต',
pipelines: 'ตัวประมวลผล',
knowledge_bases: 'ฐานความรู้',
plugins: 'ส่วนขยาย',
members: 'สมาชิก',
managed_sandbox_sessions: 'เซสชันแซนด์บ็อกซ์ที่จัดการ',
},
},
workspaceLanguage: {
label: 'ภาษาของพื้นที่ทำงาน',
description:
+67
View File
@@ -1,5 +1,72 @@
import pipelineMigration from './pipeline-migration/vi-VN';
const viVN = {
cloudPlans: {
platformNames: {
qqofficial: 'QQ Official API',
dingtalk: 'DingTalk',
discord: 'Discord',
telegram: 'Telegram',
lark: 'Lark',
slack: 'Slack',
kook: 'KOOK',
line: 'LINE',
wecom: 'WeCom',
wecombot: 'WeComBot',
wecomcs: 'WeComCustomerService',
officialaccount: 'Official Account',
wechatpad: 'WeChatPad',
openclaw_weixin: 'OpenClaw WeChat',
aiocqhttp: 'OneBot v11',
satori: 'Satori',
},
manageSubscription: 'Quản lý đăng ký',
downgradeUnavailable: 'Không thể hạ cấp xuống gói miễn phí',
features: {
multiUserWorkspace: 'Không gian làm việc nhiều người dùng',
remoteE2BSandbox: 'Cấu hình sandbox E2B từ xa',
communitySupport: 'Hỗ trợ từ cộng đồng',
everythingInFree: 'Tất cả tính năng của gói miễn phí',
alwaysOnSandbox: 'Một sandbox toàn cục luôn hoạt động',
higherWorkspaceLimits: 'Hạn mức không gian làm việc cao hơn',
},
title: 'So sánh gói',
description: 'Chọn gói cho {{workspace}}',
billing: 'Chu kỳ thanh toán',
monthly: 'Hàng tháng',
annual: 'Hàng năm',
currency: 'Tiền tệ',
loading: 'Đang tải gói…',
error: 'Không thể tải các gói',
retry: 'Thử lại',
empty: 'Không có gói khả dụng',
current: 'Gói hiện tại',
recommended: 'Đề xuất',
free: 'Miễn phí',
annualNote: 'Tương đương {{price}} / tháng',
ownerOnly: 'Chỉ chủ sở hữu hoặc quản trị viên',
choose: 'Tiếp tục thanh toán',
handoff:
'Đăng nhập và thanh toán tại Space. Lựa chọn của bạn sẽ được giữ lại.',
quotas: 'Hạn mức không gian',
unlimited: 'Không giới hạn',
retention: 'Lịch sử tin nhắn · {{count}} ngày',
retentionLabel: 'Thời gian lưu tin nhắn',
retentionHelp: 'Tin nhắn được tự động xóa sau thời gian này.',
credits: '{{count}} tín dụng mỗi tháng',
creditsLabel: 'Tín dụng hàng tháng',
creditsHelp: 'Tín dụng được cấp mỗi tháng đăng ký, kể cả gói năm.',
platforms: 'Hỗ trợ nền tảng phổ biến',
excludedPlatforms: 'Không hỗ trợ: {{platforms}}',
allPlatforms: 'Tất cả nền tảng hiện có',
limits: {
bots: 'Bot',
pipelines: 'Bộ xử lý',
knowledge_bases: 'Kho kiến thức',
plugins: 'Tiện ích mở rộng',
members: 'Thành viên',
managed_sandbox_sessions: 'Phiên sandbox được quản lý',
},
},
workspaceLanguage: {
label: 'Ngôn ngữ không gian làm việc',
description:
+66
View File
@@ -1,6 +1,72 @@
import slackSetup from './slack-setup/zh-Hans';
import pipelineMigration from './pipeline-migration/zh-Hans';
const zhHans = {
cloudPlans: {
platformNames: {
qqofficial: 'QQ 官方 API',
dingtalk: '钉钉',
discord: 'Discord',
telegram: '电报',
lark: '飞书',
slack: 'Slack',
kook: 'KOOK',
line: 'LINE',
wecom: '企业微信',
wecombot: '企业微信智能机器人',
wecomcs: '企业微信客服',
officialaccount: '微信公众号',
wechatpad: 'WeChatPad(个人微信iPad)',
openclaw_weixin: '个人微信机器人',
aiocqhttp: 'OneBot v11',
satori: 'Satori',
},
manageSubscription: '管理订阅',
downgradeUnavailable: '不支持降级到免费版',
features: {
multiUserWorkspace: '多人协作工作区',
remoteE2BSandbox: '远程 E2B 沙箱配置',
communitySupport: '社区支持',
everythingInFree: '包含免费版全部功能',
alwaysOnSandbox: '一个常驻全局沙箱',
higherWorkspaceLimits: '更高的工作区配额',
},
title: '对比订阅计划',
description: '为 {{workspace}} 选择订阅计划',
billing: '计费周期',
monthly: '月付',
annual: '年付',
currency: '币种',
loading: '正在加载计划…',
error: '无法加载订阅计划',
retry: '重试',
empty: '暂无可用计划',
current: '当前计划',
recommended: '推荐',
free: '免费',
annualNote: '相当于 {{price}} / 月',
ownerOnly: '仅所有者或管理员可操作',
choose: '前往付款',
handoff: '将在 Space 中登录并付款,所选计划、周期和币种会保留。',
quotas: '工作区配额',
unlimited: '不限',
retention: '消息记录保留 {{count}} 天',
retentionLabel: '消息记录保留时长',
retentionHelp: '超过保留时长的消息记录会自动清理。',
credits: '每月赠送 {{count}} 积分',
creditsLabel: '每月赠送积分',
creditsHelp: '每个订阅月自动发放,年付计划同样按月发放。',
platforms: '支持主流平台',
excludedPlatforms: '不支持:{{platforms}}',
allPlatforms: '支持当前所有可用平台',
limits: {
bots: '机器人',
pipelines: '处理器',
knowledge_bases: '知识库',
plugins: '扩展',
members: '成员',
managed_sandbox_sessions: '托管沙盒会话',
},
},
workspaceLanguage: {
label: '工作区语言',
description: '用于后端提示和机器人的默认回复,不影响个人界面语言。',
+66
View File
@@ -1,6 +1,72 @@
import slackSetup from './slack-setup/zh-Hant';
import pipelineMigration from './pipeline-migration/zh-Hant';
const zhHant = {
cloudPlans: {
platformNames: {
qqofficial: 'QQ 官方 API',
dingtalk: '釘釘',
discord: 'Discord',
telegram: 'Telegram',
lark: '飛書',
slack: 'Slack',
kook: 'KOOK',
line: 'LINE',
wecom: '企業微信',
wecombot: '企業微信智慧機器人',
wecomcs: '企業微信客服',
officialaccount: '微信公眾號',
wechatpad: 'WeChatPad(個人微信iPad)',
openclaw_weixin: '個人微信機器人',
aiocqhttp: 'OneBot v11',
satori: 'Satori',
},
manageSubscription: '管理訂閱',
downgradeUnavailable: '不支援降級至免費版',
features: {
multiUserWorkspace: '多人協作工作區',
remoteE2BSandbox: '遠端 E2B 沙箱設定',
communitySupport: '社群支援',
everythingInFree: '包含免費版全部功能',
alwaysOnSandbox: '一個常駐全域沙箱',
higherWorkspaceLimits: '更高的工作區配額',
},
title: '比較訂閱方案',
description: '為 {{workspace}} 選擇訂閱方案',
billing: '計費週期',
monthly: '月付',
annual: '年付',
currency: '幣別',
loading: '正在載入方案…',
error: '無法載入訂閱方案',
retry: '重試',
empty: '暫無可用方案',
current: '目前方案',
recommended: '推薦',
free: '免費',
annualNote: '相當於 {{price}} / 月',
ownerOnly: '僅擁有者或管理員可操作',
choose: '前往付款',
handoff: '將在 Space 中登入並付款,所選方案、週期和幣別會保留。',
quotas: '工作區配額',
unlimited: '不限',
retention: '訊息記錄保留 {{count}} 天',
retentionLabel: '訊息記錄保留時間',
retentionHelp: '超過保留時間的訊息記錄會自動清理。',
credits: '每月贈送 {{count}} 積分',
creditsLabel: '每月贈送積分',
creditsHelp: '每個訂閱月自動發放,年付方案同樣按月發放。',
platforms: '支援主流平台',
excludedPlatforms: '不支援:{{platforms}}',
allPlatforms: '支援目前所有可用平台',
limits: {
bots: '機器人',
pipelines: '處理器',
knowledge_bases: '知識庫',
plugins: '擴充',
members: '成員',
managed_sandbox_sessions: '託管沙盒工作階段',
},
},
workspaceLanguage: {
label: '工作區語言',
description: '用於後端提示和機器人的預設回覆,不影響個人介面語言。',
+181
View File
@@ -0,0 +1,181 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import ts from 'typescript';
const source = readFileSync(
new URL(
'../../src/app/home/components/workspace-settings/cloud-plans.ts',
import.meta.url,
),
'utf8',
);
const m = { exports: {} };
new Function(
'module',
'exports',
ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
}).outputText,
)(m, m.exports);
const {
cloudPlanCheckoutURL,
cloudPlanPrice,
localizedPlanText,
fetchPublicCloudPlans,
} = m.exports;
test('checkout links preserve the workspace and selection, never source credentials', () => {
const url = new URL(
cloudPlanCheckoutURL(
'https://space.example/prefix?token=secret#access_token=secret',
'workspace-1',
'pro & team',
'monthly',
'JPY',
),
);
assert.equal(url.pathname, '/prefix/cloud');
assert.equal(url.hash, '');
assert.deepEqual(Object.fromEntries(url.searchParams), {
environment: 'beta',
step: 'plan',
workspace: 'workspace-1',
plan: 'pro & team',
billing: 'monthly',
currency: 'JPY',
});
assert.throws(() =>
cloudPlanCheckoutURL(
'https://user:[email protected]',
'w',
'pro',
'annual',
'USD',
),
);
assert.throws(() =>
cloudPlanCheckoutURL('javascript:alert(1)', 'w', 'pro', 'annual', 'USD'),
);
});
test('prices match server annual discount and locale fallback', () => {
const plan = { price_cny: '29', price_usd: '5.5', price_jpy: '800' };
assert.equal(cloudPlanPrice(plan, 'monthly', 'USD'), 5.5);
assert.equal(cloudPlanPrice(plan, 'annual', 'CNY'), 290);
assert.equal(cloudPlanPrice(plan, 'annual', 'JPY'), 8000);
assert.equal(
localizedPlanText({ zh_Hans: '专业版', en_US: 'Pro' }, 'zh-Hans'),
'专业版',
);
assert.equal(localizedPlanText({ en_US: 'Pro' }, 'ja-JP'), 'Pro');
assert.throws(() =>
cloudPlanPrice({ ...plan, price_usd: 'NaN' }, 'monthly', 'USD'),
);
});
test('public catalog fetch omits cookies and authorization, hides inactive plans, validates response', async (t) => {
let options, url;
const plan = {
uuid: '1',
name: 'pro',
limits: {},
price_cny: '29',
price_usd: '5',
price_jpy: '800',
is_active: true,
};
t.mock.method(globalThis, 'fetch', async (u, o) => {
url = u;
options = o;
return {
ok: true,
json: async () => ({
code: 0,
data: [plan, { ...plan, uuid: 'hidden', is_active: false }],
}),
};
});
assert.deepEqual(
await fetchPublicCloudPlans(
'https://space.example',
new AbortController().signal,
),
[plan],
);
assert.equal(url.pathname, '/api/v1/cloud/v2/plans');
assert.equal(options.credentials, 'omit');
assert.equal(options.referrerPolicy, 'no-referrer');
assert.equal(options.headers, undefined);
globalThis.fetch = async () => ({ ok: false });
await assert.rejects(fetchPublicCloudPlans('https://space.example'));
globalThis.fetch = async () => ({
ok: true,
json: async () => ({ code: 0, data: [{ ...plan, price_cny: 'broken' }] }),
});
await assert.rejects(fetchPublicCloudPlans('https://space.example'));
});
const platformsModule = { exports: {} };
new Function(
'module',
'exports',
ts.transpileModule(
readFileSync(
new URL(
'../../src/app/home/components/workspace-settings/cloud-plan-platforms.ts',
import.meta.url,
),
'utf8',
),
{
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
},
).outputText,
)(platformsModule, platformsModule.exports);
const { supportedPlanPlatforms, KNOWN_ADAPTERS } = platformsModule.exports;
test('supported platforms hide blocked families and merge Omni variants', () => {
const platforms = supportedPlanPlatforms([
'aiocqhttp',
'line',
'discord-eba',
]);
const names = platforms.map((platform) => platform.name);
assert.equal(names.includes('aiocqhttp'), false);
assert.equal(names.includes('LINE'), false);
assert.equal(names.includes('discord'), true);
assert.equal(
names.some((name) => name.endsWith('-omni')),
false,
);
assert.equal(new Set(names).size, names.length);
assert.ok(platforms.every((platform) => platform.label && platform.icon));
assert.deepEqual(
supportedPlanPlatforms(KNOWN_ADAPTERS.map((adapter) => adapter.name)),
[],
);
assert.deepEqual(
supportedPlanPlatforms(['unknown-adapter']),
supportedPlanPlatforms(),
);
});
test('plan currency follows the current UI language', () => {
for (const locale of ['zh-Hans', 'zh_Hans', 'zh-Hans-CN'])
assert.equal(m.exports.cloudPlanCurrencyForLanguage(locale), 'CNY');
for (const locale of ['ja-JP', 'ja_JP', 'ja'])
assert.equal(m.exports.cloudPlanCurrencyForLanguage(locale), 'JPY');
for (const locale of [
'zh-Hant',
'zh_Hant',
'en-US',
'es-ES',
'ru-RU',
'th-TH',
'vi-VN',
])
assert.equal(m.exports.cloudPlanCurrencyForLanguage(locale), 'USD');
});