mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-22 16:35:20 +08:00
feat(cloud): add plan comparison and subscription management entry
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: '用于后端提示和机器人的默认回复,不影响个人界面语言。',
|
||||
|
||||
@@ -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: '用於後端提示和機器人的預設回覆,不影響個人介面語言。',
|
||||
|
||||
Reference in New Issue
Block a user