refactor(web): streamline workspace controls

This commit is contained in:
dadachann
2026-07-25 20:59:05 +08:00
parent 7c64cd9d51
commit 59f68b8fb4
3 changed files with 62 additions and 45 deletions
@@ -90,6 +90,9 @@ export default function WorkspaceSettingsPanel({
const cloudMembersURL = workspaceInfo const cloudMembersURL = workspaceInfo
? `${systemInfo.cloud_service_url.replace(/\/$/, '')}/cloud?workspace=${encodeURIComponent(workspaceInfo.workspace.uuid)}#workspace-members` ? `${systemInfo.cloud_service_url.replace(/\/$/, '')}/cloud?workspace=${encodeURIComponent(workspaceInfo.workspace.uuid)}#workspace-members`
: ''; : '';
const cloudPortalURL = workspaceInfo
? `${systemInfo.cloud_service_url.replace(/\/$/, '')}/cloud?workspace=${encodeURIComponent(workspaceInfo.workspace.uuid)}&step=plan`
: '';
const loadWorkspace = useCallback(async () => { const loadWorkspace = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -221,6 +224,14 @@ export default function WorkspaceSettingsPanel({
{t(`workspace.roles.${workspaceInfo.membership.role}`)} {t(`workspace.roles.${workspaceInfo.membership.role}`)}
</Badge> </Badge>
)} )}
{isCloudProjection && workspaceInfo && (
<Button asChild size="sm" variant="outline">
<a href={cloudPortalURL} target="_blank" rel="noopener noreferrer">
{t('workspace.upgradePlan')}
<ExternalLink className="size-3.5" />
</a>
</Button>
)}
{canManageCloudMembers && ( {canManageCloudMembers && (
<Button asChild size="sm" variant="outline"> <Button asChild size="sm" variant="outline">
<a href={cloudMembersURL} target="_blank" rel="noopener noreferrer"> <a href={cloudMembersURL} target="_blank" rel="noopener noreferrer">
@@ -1,15 +1,8 @@
import { import { Building2, Check, Settings } from 'lucide-react';
Building2,
Check,
ExternalLink,
Settings,
Sparkles,
} from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
switchWorkspaceAndReload, switchWorkspaceAndReload,
systemInfo,
useCurrentWorkspace, useCurrentWorkspace,
useWorkspaceBootstrap, useWorkspaceBootstrap,
} from '@/app/infra/http'; } from '@/app/infra/http';
@@ -19,7 +12,6 @@ import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -38,77 +30,72 @@ export default function WorkspaceSwitcher({
const { t } = useTranslation(); const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspace(); const currentWorkspace = useCurrentWorkspace();
const workspaces = useWorkspaceBootstrap(); const workspaces = useWorkspaceBootstrap();
const isCloud = currentWorkspace?.workspace.source === 'cloud_projection';
if (!currentWorkspace) return null; if (!currentWorkspace) return null;
const cloudPortalUrl = `${systemInfo.cloud_service_url.replace(/\/$/, '')}/cloud?workspace=${encodeURIComponent(currentWorkspace.workspace.uuid)}`;
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className={cn('min-w-44 max-w-64 justify-start', className)} className={cn(
'h-11 min-w-52 max-w-72 justify-start px-3 text-[15px]',
className,
)}
aria-label={t('workspace.switchWorkspace')} aria-label={t('workspace.switchWorkspace')}
> >
<Building2 className="size-4 shrink-0" /> <Building2 className="size-4 shrink-0" />
<span className="truncate">{currentWorkspace.workspace.name}</span> <span className="truncate">{currentWorkspace.workspace.name}</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-64"> <DropdownMenuContent align="end" className="min-w-80 p-2">
<DropdownMenuLabel>{t('workspace.switchWorkspace')}</DropdownMenuLabel> <DropdownMenuLabel className="px-3 py-2 text-sm">
{t('workspace.switchWorkspace')}
</DropdownMenuLabel>
{workspaces.map((entry) => { {workspaces.map((entry) => {
const selected = const selected =
entry.workspace.uuid === currentWorkspace.workspace.uuid; entry.workspace.uuid === currentWorkspace.workspace.uuid;
return ( return (
<DropdownMenuItem <DropdownMenuItem
key={entry.workspace.uuid} key={entry.workspace.uuid}
className="min-h-14 gap-3 px-3 py-2"
onClick={() => { onClick={() => {
if (!selected) if (!selected)
void switchWorkspaceAndReload(entry.workspace.uuid); void switchWorkspaceAndReload(entry.workspace.uuid);
}} }}
> >
<Building2 className="size-4" /> <Building2 className="size-5" />
<span className="min-w-0 flex-1 truncate"> <span className="min-w-0 flex-1 truncate font-medium">
{entry.workspace.name} {entry.workspace.name}
</span> </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>
)}
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{t(`workspace.roles.${entry.membership.role}`)} {t(`workspace.roles.${entry.membership.role}`)}
</span> </span>
{selected && <Check className="size-4" />} {selected && <Check className="size-4" />}
{selected && (
<Button
variant="ghost"
size="icon"
className="size-8"
aria-label={t('workspace.settings')}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
requestWorkspaceSettings();
}}
>
<Settings className="size-4" />
</Button>
)}
</DropdownMenuItem> </DropdownMenuItem>
); );
})} })}
<DropdownMenuSeparator />
{isCloud && (
<>
<DropdownMenuLabel className="flex items-center justify-between gap-3 font-normal">
<span className="text-muted-foreground">
{t('workspace.currentPlan')}
</span>
<span className="font-medium text-foreground">
{currentWorkspace.plan_name || t('workspace.planUnavailable')}
</span>
</DropdownMenuLabel>
<DropdownMenuItem asChild>
<a
href={cloudPortalUrl}
target="_blank"
rel="noopener noreferrer"
>
<Sparkles className="size-4" />
{t('workspace.upgradePlan')}
<ExternalLink className="ml-auto size-3.5" />
</a>
</DropdownMenuItem>
</>
)}
<DropdownMenuItem onClick={requestWorkspaceSettings}>
<Settings className="size-4" />
{t('workspace.settings')}
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); );
@@ -67,3 +67,22 @@ test('links Cloud workspace managers to the Space member invitation surface', ()
assert.match(workspaceSettingsPanelSource, /#workspace-members/); assert.match(workspaceSettingsPanelSource, /#workspace-members/);
assert.match(workspaceSettingsPanelSource, /workspace\.inviteMember/); assert.match(workspaceSettingsPanelSource, /workspace\.inviteMember/);
}); });
test('keeps workspace plan and settings controls on the workspace row', () => {
assert.match(workspaceSwitcherSource, /entry\.plan_name/);
assert.match(workspaceSwitcherSource, /aria-label=\{t\('workspace\.settings'\)\}/);
assert.match(workspaceSwitcherSource, /className="size-8"/);
assert.doesNotMatch(workspaceSwitcherSource, /workspace\.currentPlan/);
assert.doesNotMatch(workspaceSwitcherSource, /workspace\.upgradePlan/);
});
test('moves Cloud plan upgrades into Workspace Settings', () => {
assert.match(workspaceSettingsPanelSource, /workspace\.upgradePlan/);
assert.match(workspaceSettingsPanelSource, /cloudPortalURL/);
assert.match(workspaceSettingsPanelSource, /step=plan/);
});
test('uses a larger workspace trigger and menu surface', () => {
assert.match(workspaceSwitcherSource, /h-11/);
assert.match(workspaceSwitcherSource, /min-w-80/);
});