mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-18 01:38:03 +00:00
Compare commits
1 Commits
split/node
...
split/plan
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85f9a5347d |
@@ -59,9 +59,6 @@ const config: KnipConfig = {
|
||||
// + split/allowlist (Pagination); each consumer removes its entry
|
||||
'src/components/ui/switch/Switch.vue',
|
||||
'src/components/ui/pagination/Pagination.vue',
|
||||
// Pending integration: consumed by split/plan-credits-tabs (Overview) and
|
||||
// split/allowlist (Models); each consumer removes this entry
|
||||
'src/platform/workspace/composables/useAutoPageSize.ts',
|
||||
// Marketing media tooling — adopted by pages in a follow-up PR
|
||||
'apps/website/src/components/common/SiteVideo.vue',
|
||||
'apps/website/src/utils/marketingImage.ts',
|
||||
|
||||
16
src/components/ui/tabs/Tabs.vue
Normal file
16
src/components/ui/tabs/Tabs.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { TabsRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
import type { TabsRootEmits, TabsRootProps } from 'reka-ui'
|
||||
|
||||
// eslint-disable-next-line vue/no-unused-properties -- forwarded to Reka via useForwardPropsEmits
|
||||
const props = defineProps<TabsRootProps>()
|
||||
const emits = defineEmits<TabsRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</TabsRoot>
|
||||
</template>
|
||||
20
src/components/ui/tabs/TabsList.vue
Normal file
20
src/components/ui/tabs/TabsList.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { TabsList } from 'reka-ui'
|
||||
import type { TabsListProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className, ...rest } = defineProps<
|
||||
TabsListProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsList
|
||||
v-bind="rest"
|
||||
:class="cn('inline-flex items-center gap-4', className)"
|
||||
>
|
||||
<slot />
|
||||
</TabsList>
|
||||
</template>
|
||||
28
src/components/ui/tabs/TabsTrigger.vue
Normal file
28
src/components/ui/tabs/TabsTrigger.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { TabsTrigger, useForwardProps } from 'reka-ui'
|
||||
import type { TabsTriggerProps } from 'reka-ui'
|
||||
import { computed } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className, ...rest } = defineProps<
|
||||
TabsTriggerProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
|
||||
const forwarded = useForwardProps(computed(() => rest))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsTrigger
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'cursor-pointer appearance-none border-0 border-b-2 border-transparent bg-transparent px-0 pb-2 text-sm text-muted-foreground transition-colors outline-none data-[state=active]:border-base-foreground data-[state=active]:text-base-foreground',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</TabsTrigger>
|
||||
</template>
|
||||
@@ -3058,6 +3058,17 @@
|
||||
"recentActivity": "Recent activity",
|
||||
"topSpenders": "Top spenders",
|
||||
"user": "User"
|
||||
},
|
||||
"nextInvoice": "Next month invoice",
|
||||
"usd": "USD"
|
||||
},
|
||||
"invoices": {
|
||||
"fullHistory": "Full invoice history"
|
||||
},
|
||||
"planCredits": {
|
||||
"tabs": {
|
||||
"invoices": "Invoices",
|
||||
"overview": "Credits"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -233,5 +233,12 @@ describe('useSettingUI', () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('splits the workspace panel into plan and members sidebar entries', () => {
|
||||
const { navGroups } = useSettingUI()
|
||||
const keys = navKeys(navGroups.value)
|
||||
expect(keys).toContain('workspace')
|
||||
expect(keys).toContain('workspace-members')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,13 +27,14 @@ const CATEGORY_ICONS: Record<string, string> = {
|
||||
keybinding: 'icon-[lucide--keyboard]',
|
||||
LiteGraph: 'icon-[lucide--workflow]',
|
||||
'Mask Editor': 'icon-[lucide--pen-tool]',
|
||||
Members: 'icon-[lucide--users]',
|
||||
Other: 'icon-[lucide--ellipsis]',
|
||||
PlanCredits: 'icon-[lucide--credit-card]',
|
||||
PlanCredits: 'icon-[lucide--receipt-text]',
|
||||
secrets: 'icon-[lucide--key-round]',
|
||||
'server-config': 'icon-[lucide--server]',
|
||||
subscription: 'icon-[lucide--credit-card]',
|
||||
user: 'icon-[lucide--user]',
|
||||
workspace: 'icon-[lucide--building-2]'
|
||||
workspace: 'icon-[lucide--receipt-text]'
|
||||
}
|
||||
|
||||
interface SettingPanelItem {
|
||||
@@ -175,16 +176,18 @@ export function useSettingUI(
|
||||
)
|
||||
}
|
||||
|
||||
// Workspace panel: only available on cloud with team workspaces enabled
|
||||
const workspacePanel: SettingPanelItem = {
|
||||
// Workspace panels: only available on cloud with team workspaces enabled.
|
||||
// The old single "Workspace" panel is split into three sidebar entries; the
|
||||
// Plan & Credits entry keeps the 'workspace' key so existing deep links land.
|
||||
const planCreditsPanel: SettingPanelItem = {
|
||||
node: {
|
||||
key: 'workspace',
|
||||
label: 'Workspace',
|
||||
label: 'PlanCredits',
|
||||
children: []
|
||||
},
|
||||
component: defineAsyncComponent(
|
||||
() =>
|
||||
import('@/platform/workspace/components/dialogs/settings/WorkspacePanelContent.vue')
|
||||
import('@/platform/workspace/components/dialogs/settings/PlanCreditsPanelContent.vue')
|
||||
)
|
||||
}
|
||||
|
||||
@@ -257,7 +260,9 @@ export function useSettingUI(
|
||||
aboutPanel,
|
||||
creditsPanel,
|
||||
userPanel,
|
||||
...(shouldShowWorkspacePanel.value ? [workspacePanel, membersPanel] : []),
|
||||
...(shouldShowWorkspacePanel.value
|
||||
? [planCreditsPanel, membersPanel]
|
||||
: []),
|
||||
keybindingPanel,
|
||||
extensionPanel,
|
||||
...(isDesktop ? [serverConfigPanel] : []),
|
||||
@@ -308,9 +313,12 @@ export function useSettingUI(
|
||||
label: 'Workspace',
|
||||
children: [
|
||||
...(shouldShowWorkspacePanel.value
|
||||
? [workspacePanel.node, membersPanel.node]
|
||||
? [planCreditsPanel.node, membersPanel.node]
|
||||
: []),
|
||||
...(isLoggedIn.value &&
|
||||
// The legacy per-account Credits panel is redundant once the workspace
|
||||
// Plan & Credits panel is present, which now owns the credit balance.
|
||||
...(!shouldShowWorkspacePanel.value &&
|
||||
isLoggedIn.value &&
|
||||
!(isCloud && window.__CONFIG__?.subscription_required)
|
||||
? [creditsPanel.node]
|
||||
: [])
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'@container flex min-h-0 flex-1 flex-col gap-4',
|
||||
// The panel runs flush to the bottom edge (BaseModalLayout 'flush');
|
||||
// Invoices keeps its prior bottom gap, Overview doesn't.
|
||||
activeView !== 'overview' && 'pb-6'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex w-full items-center gap-2">
|
||||
<Button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
:variant="activeView === tab.key ? 'secondary' : 'muted-textonly'"
|
||||
size="lg"
|
||||
@click="setView(tab.key)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<BillingStatusBanner>
|
||||
<template #actions>
|
||||
<Button
|
||||
v-if="activeView === 'invoices' && isPaused"
|
||||
variant="textonly"
|
||||
size="lg"
|
||||
@click="openInvoiceHistory"
|
||||
>
|
||||
{{ $t('workspacePanel.invoices.fullHistory') }}
|
||||
<i class="icon-[lucide--external-link] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
</BillingStatusBanner>
|
||||
|
||||
<WorkspaceOverviewContent v-if="activeView === 'overview'" />
|
||||
<WorkspaceInvoicesContent v-else />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import BillingStatusBanner from '@/platform/workspace/components/dialogs/settings/BillingStatusBanner.vue'
|
||||
import WorkspaceOverviewContent from '@/platform/workspace/components/dialogs/settings/WorkspaceOverviewContent.vue'
|
||||
import WorkspaceInvoicesContent from '@/platform/workspace/components/dialogs/settings/WorkspaceInvoicesContent.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
type View = 'overview' | 'invoices'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { permissions } = useWorkspaceUI()
|
||||
const { isPaused, manageSubscription } = useBillingContext()
|
||||
|
||||
function openInvoiceHistory() {
|
||||
void manageSubscription()
|
||||
}
|
||||
|
||||
const tabs = computed(() => {
|
||||
const base: { key: View; label: string }[] = [
|
||||
{ key: 'overview', label: t('workspacePanel.planCredits.tabs.overview') }
|
||||
]
|
||||
// Invoices are billing details — owners/admins only.
|
||||
if (permissions.value.canManageSubscription) {
|
||||
base.push({
|
||||
key: 'invoices',
|
||||
label: t('workspacePanel.planCredits.tabs.invoices')
|
||||
})
|
||||
}
|
||||
return base
|
||||
})
|
||||
const activeView = ref<View>('overview')
|
||||
|
||||
function setView(view: View) {
|
||||
activeView.value = view
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-4">
|
||||
<!-- Invoice history and downloads live in Stripe, so this tab just surfaces
|
||||
the upcoming charge and links out; the local history table is hidden
|
||||
for now. When paused there's no upcoming invoice — the parent's
|
||||
"Subscription paused" banner covers that state and hosts the history
|
||||
link, so we drop this banner to avoid stacking two. Hidden until the
|
||||
API can supply the upcoming amount. -->
|
||||
<div
|
||||
v-if="!isPaused && nextInvoiceCents !== null"
|
||||
class="flex flex-col gap-4 rounded-2xl border border-interface-stroke/60 p-4 @2xl:flex-row @2xl:items-center @2xl:justify-between"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.overview.nextInvoice') }}
|
||||
</span>
|
||||
<p class="m-0 text-2xl font-semibold text-base-foreground">
|
||||
{{ formatWholeUsd(nextInvoiceCents) }}
|
||||
<span class="text-base font-normal text-base-foreground">
|
||||
{{ $t('workspacePanel.overview.usd') }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="secondary" size="lg" @click="openHistory">
|
||||
{{ $t('workspacePanel.invoices.fullHistory') }}
|
||||
<i class="icon-[lucide--external-link] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useWorkspaceInvoices } from '@/platform/workspace/composables/useWorkspaceInvoices'
|
||||
|
||||
const { n } = useI18n()
|
||||
const { isPaused, manageSubscription } = useBillingContext()
|
||||
|
||||
const { nextInvoiceCents } = useWorkspaceInvoices()
|
||||
|
||||
function formatWholeUsd(cents: number | null): string {
|
||||
if (cents === null) return ''
|
||||
|
||||
return n(cents / 100, {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
})
|
||||
}
|
||||
|
||||
function openHistory() {
|
||||
void manageSubscription()
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,385 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex min-h-0 scrollbar-hide flex-1 flex-col gap-4 overflow-y-auto pb-6"
|
||||
>
|
||||
<!-- Plan + credits + member snapshot -->
|
||||
<div
|
||||
class="flex flex-col gap-6 rounded-2xl border border-interface-stroke/60 p-6"
|
||||
>
|
||||
<!-- Lapsed team/enterprise plan: reactivation header replaces the live one -->
|
||||
<div
|
||||
v-if="isInactive"
|
||||
class="flex flex-col gap-4 @4xl:flex-row @4xl:items-start @4xl:justify-between"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ inactiveTitle }}
|
||||
</span>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ inactiveSubtitle }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="canManageBilling" class="flex shrink-0 items-center gap-2">
|
||||
<Button variant="secondary" size="lg" @click="manageSubscription">
|
||||
{{ $t('workspacePanel.overview.managePayment') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="inverted"
|
||||
size="lg"
|
||||
:loading="isResubscribing"
|
||||
@click="handleResubscribe"
|
||||
>
|
||||
{{ $t('workspacePanel.overview.inactive.reactivate') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col gap-4 @4xl:flex-row @4xl:items-start @4xl:justify-between"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-sm text-base-foreground">{{ plan.name }}</span>
|
||||
<p
|
||||
v-if="canManageBilling"
|
||||
class="m-0 flex items-center gap-1.5 text-base font-semibold whitespace-nowrap text-base-foreground"
|
||||
>
|
||||
<i class="icon-[lucide--coins] size-4 text-credit" />
|
||||
{{ plan.monthlyCredits.toLocaleString() }}
|
||||
<span class="text-base font-normal text-muted-foreground">
|
||||
/ {{ $t('workspacePanel.overview.perMonth') }}
|
||||
</span>
|
||||
</p>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{
|
||||
isPaused
|
||||
? $t('workspacePanel.overview.paused')
|
||||
: $t('workspacePanel.overview.renewsOn', {
|
||||
date: plan.renewalLabel
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="canManageBilling" class="flex shrink-0 items-center gap-2">
|
||||
<Button variant="secondary" size="lg" @click="manageSubscription">
|
||||
{{ $t('workspacePanel.overview.managePayment') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isOriginalOwner"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
@click="handleChangePlan"
|
||||
>
|
||||
{{ $t('workspacePanel.overview.changePlan') }}
|
||||
</Button>
|
||||
<DropdownMenu
|
||||
v-if="planMenuEntries.length > 0"
|
||||
:entries="planMenuEntries"
|
||||
:modal="false"
|
||||
>
|
||||
<template #button>
|
||||
<Button
|
||||
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
|
||||
variant="secondary"
|
||||
size="icon-lg"
|
||||
class="rounded-lg"
|
||||
:aria-label="$t('g.moreOptions')"
|
||||
>
|
||||
<i class="icon-[lucide--ellipsis] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 @4xl:grid-cols-2">
|
||||
<CreditsTile class="border-0" :frozen="isInactive" />
|
||||
|
||||
<!-- Member snapshot tile (hidden while the plan is lapsed) -->
|
||||
<div
|
||||
v-if="canManageBilling && !isInactive"
|
||||
class="flex flex-col gap-3 rounded-xl bg-modal-panel-background px-6 py-5"
|
||||
>
|
||||
<Tabs v-model="snapshotView">
|
||||
<TabsList>
|
||||
<TabsTrigger value="top">
|
||||
{{ $t('workspacePanel.overview.snapshot.topSpenders') }}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="recent">
|
||||
{{ $t('workspacePanel.overview.snapshot.recentActivity') }}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div
|
||||
v-if="snapshotEmptyMessage"
|
||||
class="flex flex-1 flex-col items-center justify-center gap-3 py-6 text-center"
|
||||
>
|
||||
<i class="icon-[lucide--coins] size-6 text-muted-foreground" />
|
||||
<p class="m-0 text-sm text-base-foreground">
|
||||
{{ snapshotEmptyMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
ref="snapshotContainer"
|
||||
class="min-h-0 flex-1 overflow-hidden"
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow
|
||||
class="hover:bg-transparent [&>th]:h-9 [&>th]:border-b [&>th]:border-interface-stroke/60"
|
||||
>
|
||||
<TableHead>
|
||||
{{ $t('workspacePanel.overview.snapshot.user') }}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{{ $t('workspacePanel.overview.snapshot.lastActivity') }}
|
||||
</TableHead>
|
||||
<TableHead class="text-right">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<i class="icon-[lucide--coins] size-4" />
|
||||
{{ $t('workspacePanel.overview.snapshot.creditsUsed') }}
|
||||
</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="row in snapshotRows"
|
||||
:key="row.userName"
|
||||
class="hover:bg-transparent [&:last-child>td]:border-b-0 [&>td]:border-b [&>td]:border-interface-stroke/20"
|
||||
>
|
||||
<TableCell>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="flex size-5 shrink-0 items-center justify-center rounded-full"
|
||||
:style="{ backgroundColor: row.color }"
|
||||
>
|
||||
<span class="text-2xs font-bold text-base-foreground">
|
||||
{{ row.userName.charAt(0).toUpperCase() }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ row.userName }}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-sm text-muted-foreground tabular-nums">
|
||||
{{ row.lastActivity }}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class="text-right text-sm text-base-foreground tabular-nums"
|
||||
>
|
||||
{{ row.credits.toLocaleString() }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="lg"
|
||||
class="mt-auto w-full"
|
||||
@click="handleSeeMore"
|
||||
>
|
||||
{{ $t('workspacePanel.overview.seeMore') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- mt-auto floats the footer to the panel's bottom edge; pb-6 (matching
|
||||
the other tabs) keeps it level with their footers. -->
|
||||
<div
|
||||
class="mt-auto flex shrink-0 flex-wrap items-center gap-x-4 gap-y-2 text-sm text-muted-foreground @2xl:h-8"
|
||||
>
|
||||
<a
|
||||
:href="learnMoreUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex cursor-pointer items-center gap-1 text-muted-foreground no-underline transition-colors hover:text-base-foreground"
|
||||
>
|
||||
<i class="icon-[lucide--circle-help] size-4" />
|
||||
{{ $t('workspacePanel.overview.learnMore') }}
|
||||
</a>
|
||||
<a
|
||||
:href="partnerNodesPricingUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex cursor-pointer items-center gap-1 text-muted-foreground no-underline transition-colors hover:text-base-foreground"
|
||||
>
|
||||
<i class="icon-[lucide--circle-help] size-4" />
|
||||
{{ $t('workspacePanel.overview.pricingTable') }}
|
||||
</a>
|
||||
<button
|
||||
class="flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 font-[inherit] text-sm text-muted-foreground transition-colors hover:text-base-foreground"
|
||||
@click="openSupport"
|
||||
>
|
||||
<i class="icon-[lucide--message-circle] size-4" />
|
||||
{{ $t('workspacePanel.overview.messageSupport') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
|
||||
import DropdownMenu from '@/components/common/DropdownMenu.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Table from '@/components/ui/table/Table.vue'
|
||||
import TableBody from '@/components/ui/table/TableBody.vue'
|
||||
import TableCell from '@/components/ui/table/TableCell.vue'
|
||||
import TableHead from '@/components/ui/table/TableHead.vue'
|
||||
import TableHeader from '@/components/ui/table/TableHeader.vue'
|
||||
import TableRow from '@/components/ui/table/TableRow.vue'
|
||||
import Tabs from '@/components/ui/tabs/Tabs.vue'
|
||||
import TabsList from '@/components/ui/tabs/TabsList.vue'
|
||||
import TabsTrigger from '@/components/ui/tabs/TabsTrigger.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useSettingsNavigation } from '@/platform/settings/composables/useSettingsNavigation'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import CreditsTile from '@/platform/cloud/subscription/components/CreditsTile.vue'
|
||||
import { buildSupportUrl } from '@/platform/support/config'
|
||||
import { useAutoPageSize } from '@/platform/workspace/composables/useAutoPageSize'
|
||||
import { requestMembersSort } from '@/platform/workspace/composables/useMembersPanel'
|
||||
import { useResubscribe } from '@/platform/workspace/composables/useResubscribe'
|
||||
import { useTeamPlan } from '@/platform/workspace/composables/useTeamPlan'
|
||||
import { useWorkspaceOverview } from '@/platform/workspace/composables/useWorkspaceOverview'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Plan lifecycle actions are for the workspace creator (Owner) only; Admins and
|
||||
// Members don't see Change plan or the overflow menu.
|
||||
const {
|
||||
isOriginalOwner,
|
||||
isActiveSubscription,
|
||||
isTeamPlanCancelled,
|
||||
permissions
|
||||
} = useWorkspaceUI()
|
||||
|
||||
// Members can't manage or view billing details — only the credit balance. Gates
|
||||
// the plan price, payment/plan actions, snapshot, next invoice, and auto-reload.
|
||||
const canManageBilling = computed(() => permissions.value.canManageSubscription)
|
||||
const {
|
||||
isFreeTier,
|
||||
isPaused,
|
||||
subscription,
|
||||
manageSubscription,
|
||||
showSubscriptionDialog
|
||||
} = useBillingContext()
|
||||
const { showCancelSubscriptionDialog } = useDialogService()
|
||||
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
function handleChangePlan() {
|
||||
if (isFreeTier.value) showPricingTable({ reason: 'settings_billing_panel' })
|
||||
else showSubscriptionDialog({ reason: 'settings_billing_panel' })
|
||||
}
|
||||
|
||||
// A team (or enterprise) workspace whose plan has lapsed shows the reactivation
|
||||
// state in place of the live plan header, snapshot, and auto-reload.
|
||||
const { hasLapsedTeamPlan: isInactive } = useTeamPlan()
|
||||
const { isResubscribing, handleResubscribe } = useResubscribe()
|
||||
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
const learnMoreUrl = buildDocsUrl('/get_started/cloud', {
|
||||
includeLocale: true
|
||||
})
|
||||
const partnerNodesPricingUrl = buildDocsUrl(docsPaths.partnerNodesPricing, {
|
||||
includeLocale: true
|
||||
})
|
||||
|
||||
const { userEmail, resolvedUserInfo } = useCurrentUser()
|
||||
function openSupport() {
|
||||
const url = buildSupportUrl({
|
||||
userEmail: userEmail.value,
|
||||
userId: resolvedUserInfo.value?.id
|
||||
})
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
const canCancelPlan = computed(
|
||||
() =>
|
||||
isOriginalOwner.value &&
|
||||
isActiveSubscription.value &&
|
||||
!isTeamPlanCancelled.value &&
|
||||
!isFreeTier.value
|
||||
)
|
||||
|
||||
const planMenuEntries = computed<MenuItem[]>(() =>
|
||||
canCancelPlan.value
|
||||
? [
|
||||
{
|
||||
label: t('subscription.cancelPlan'),
|
||||
command: () =>
|
||||
void showCancelSubscriptionDialog(
|
||||
subscription.value?.endDate ?? undefined
|
||||
)
|
||||
}
|
||||
]
|
||||
: []
|
||||
)
|
||||
|
||||
const { plan, topSpenders, recentActivity } = useWorkspaceOverview()
|
||||
|
||||
// Enterprise workspaces read "enterprise" in the lapsed copy, not "team".
|
||||
const inactiveTitle = computed(() =>
|
||||
plan.value.name === 'Enterprise'
|
||||
? t('workspacePanel.overview.inactive.titleEnterprise')
|
||||
: t('workspacePanel.overview.inactive.title')
|
||||
)
|
||||
const inactiveSubtitle = computed(() =>
|
||||
plan.value.name === 'Enterprise'
|
||||
? t('workspacePanel.overview.inactive.subtitleEnterprise')
|
||||
: t('workspacePanel.overview.inactive.subtitle')
|
||||
)
|
||||
|
||||
const { navigateToPanel } = useSettingsNavigation()
|
||||
|
||||
// The credits tile dictates the row height; the snapshot tile fits as many
|
||||
// user rows as that height allows, keeping "See more" pinned to the bottom.
|
||||
const snapshotContainer = ref<HTMLElement | null>(null)
|
||||
const { pageSize: visibleSnapshotRows } = useAutoPageSize(snapshotContainer, 1)
|
||||
|
||||
const snapshotView = ref('top')
|
||||
const snapshotRows = computed(() =>
|
||||
(snapshotView.value === 'top'
|
||||
? topSpenders.value
|
||||
: recentActivity.value
|
||||
).slice(0, visibleSnapshotRows.value)
|
||||
)
|
||||
|
||||
// Each tab gets its own empty state: a top-spenders leaderboard of all-zeros
|
||||
// (a fresh billing cycle) is meaningless, and recent activity can simply have
|
||||
// no events yet. Returns the message to show, or null when the table has rows.
|
||||
const snapshotEmptyMessage = computed(() => {
|
||||
if (snapshotView.value === 'top') {
|
||||
return topSpenders.value.some((row) => row.credits > 0)
|
||||
? null
|
||||
: t('workspacePanel.overview.snapshot.empty.topSpenders')
|
||||
}
|
||||
return recentActivity.value.length === 0
|
||||
? t('workspacePanel.overview.snapshot.empty.recentActivity')
|
||||
: null
|
||||
})
|
||||
|
||||
// Both snapshot tabs deep-link to the Members panel (sorted by spend or
|
||||
// recency) until the Activity tab exists.
|
||||
function handleSeeMore() {
|
||||
requestMembersSort(
|
||||
snapshotView.value === 'recent' ? 'lastActivity' : 'credits'
|
||||
)
|
||||
navigateToPanel('workspace-members')
|
||||
}
|
||||
</script>
|
||||
@@ -1,129 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { WorkspaceMember } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
import WorkspacePanelContent from './WorkspacePanelContent.vue'
|
||||
|
||||
const mockFetchMembers = vi.fn()
|
||||
const mockFetchPendingInvites = vi.fn()
|
||||
|
||||
const { mockMembers, mockWorkspaceType } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
const { ref } = require('vue') as typeof import('vue')
|
||||
|
||||
return {
|
||||
mockMembers: ref<WorkspaceMember[]>([]),
|
||||
mockWorkspaceType: ref<'personal' | 'team'>('team')
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('pinia', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...(actual as object),
|
||||
storeToRefs: (store: Record<string, unknown>) => store
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
const { ref } = require('vue') as typeof import('vue')
|
||||
return {
|
||||
useTeamWorkspaceStore: () => ({
|
||||
workspaceName: ref('Acme Team'),
|
||||
members: mockMembers,
|
||||
fetchMembers: mockFetchMembers,
|
||||
fetchPendingInvites: mockFetchPendingInvites
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
const { ref } = require('vue') as typeof import('vue')
|
||||
return {
|
||||
useWorkspaceUI: () => ({
|
||||
workspaceType: mockWorkspaceType,
|
||||
workspaceRole: ref('owner')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue',
|
||||
() => ({
|
||||
default: { name: 'SubscriptionPanelContentWorkspace', template: '<div />' }
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workspace/components/dialogs/settings/MembersPanelContent.vue',
|
||||
() => ({
|
||||
default: { name: 'MembersPanelContent', template: '<div />' }
|
||||
})
|
||||
)
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} },
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
|
||||
function createMember(id: string): WorkspaceMember {
|
||||
return {
|
||||
id,
|
||||
name: `Member ${id}`,
|
||||
email: `member${id}@example.com`,
|
||||
joinDate: new Date('2025-01-15'),
|
||||
role: 'member',
|
||||
isOriginalOwner: false
|
||||
}
|
||||
}
|
||||
|
||||
function renderComponent() {
|
||||
return render(WorkspacePanelContent, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: { WorkspaceProfilePic: true }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('WorkspacePanelContent members tab label', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockMembers.value = []
|
||||
mockWorkspaceType.value = 'team'
|
||||
})
|
||||
|
||||
it('shows the counted label for team workspaces with multiple members', () => {
|
||||
mockMembers.value = [createMember('1'), createMember('2')]
|
||||
renderComponent()
|
||||
expect(screen.getByText(/workspacePanel\.tabs\.membersCount/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('drops the count when the owner is the only member', () => {
|
||||
mockMembers.value = [createMember('1')]
|
||||
renderComponent()
|
||||
expect(screen.getByText('workspacePanel.members.header')).toBeTruthy()
|
||||
expect(screen.queryByText(/workspacePanel\.tabs\.membersCount/)).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the plain Members label for personal workspaces', () => {
|
||||
mockWorkspaceType.value = 'personal'
|
||||
mockMembers.value = [createMember('1'), createMember('2')]
|
||||
renderComponent()
|
||||
expect(screen.getByText('workspacePanel.members.header')).toBeTruthy()
|
||||
expect(screen.queryByText(/workspacePanel\.tabs\.membersCount/)).toBeNull()
|
||||
})
|
||||
|
||||
it('fetches members and pending invites on mount', () => {
|
||||
renderComponent()
|
||||
expect(mockFetchMembers).toHaveBeenCalled()
|
||||
expect(mockFetchPendingInvites).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,95 +0,0 @@
|
||||
<template>
|
||||
<div class="flex size-full flex-col">
|
||||
<header class="mb-6 flex items-center gap-4">
|
||||
<WorkspaceProfilePic
|
||||
class="size-12 text-3xl!"
|
||||
:workspace-name="workspaceName"
|
||||
/>
|
||||
<h1 class="text-3xl font-semibold text-base-foreground">
|
||||
{{ workspaceName }}
|
||||
</h1>
|
||||
</header>
|
||||
<TabsRoot v-model="activeTab">
|
||||
<TabsList class="flex items-center gap-2 pb-1">
|
||||
<TabsTrigger
|
||||
value="plan"
|
||||
:class="
|
||||
cn(
|
||||
tabTriggerBase,
|
||||
activeTab === 'plan' ? tabTriggerActive : tabTriggerInactive
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ $t('workspacePanel.tabs.planCredits') }}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="members"
|
||||
:class="
|
||||
cn(
|
||||
tabTriggerBase,
|
||||
activeTab === 'members' ? tabTriggerActive : tabTriggerInactive
|
||||
)
|
||||
"
|
||||
>
|
||||
{{
|
||||
showMembersTabCount
|
||||
? $t('workspacePanel.tabs.membersCount', {
|
||||
count: members.length
|
||||
})
|
||||
: $t('workspacePanel.members.header')
|
||||
}}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="plan" class="mt-4">
|
||||
<SubscriptionPanelContentWorkspace />
|
||||
</TabsContent>
|
||||
<TabsContent value="members" class="mt-4">
|
||||
<MembersPanelContent :key="workspaceRole" />
|
||||
</TabsContent>
|
||||
</TabsRoot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
|
||||
|
||||
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
|
||||
import MembersPanelContent from '@/platform/workspace/components/dialogs/settings/MembersPanelContent.vue'
|
||||
import SubscriptionPanelContentWorkspace from '@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const tabTriggerBase =
|
||||
'flex items-center justify-center shrink-0 px-2.5 py-2 text-sm rounded-lg cursor-pointer transition-all duration-200 outline-hidden border-none'
|
||||
const tabTriggerActive =
|
||||
'bg-interface-menu-component-surface-hovered text-text-primary font-bold'
|
||||
const tabTriggerInactive =
|
||||
'bg-transparent text-text-secondary hover:bg-button-hover-surface focus:bg-button-hover-surface'
|
||||
|
||||
const { defaultTab = 'plan' } = defineProps<{
|
||||
defaultTab?: string
|
||||
}>()
|
||||
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const { workspaceName, members } = storeToRefs(workspaceStore)
|
||||
const { fetchMembers, fetchPendingInvites } = workspaceStore
|
||||
|
||||
const { workspaceType, workspaceRole } = useWorkspaceUI()
|
||||
const isPersonalWorkspace = computed(() => workspaceType.value === 'personal')
|
||||
const activeTab = ref(defaultTab)
|
||||
|
||||
// Per design, the tab counts members only when there is more than the owner
|
||||
const showMembersTabCount = computed(
|
||||
() => !isPersonalWorkspace.value && members.value.length > 1
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
fetchMembers()
|
||||
fetchPendingInvites()
|
||||
})
|
||||
</script>
|
||||
@@ -30,6 +30,13 @@ type SortField =
|
||||
| 'expiryDate'
|
||||
type SortDirection = 'asc' | 'desc'
|
||||
|
||||
// One-shot sort applied the next time the Members panel mounts, so other panels
|
||||
// can deep-link in with a preset ordering (e.g. Overview "Top spenders").
|
||||
const pendingSort = ref<SortField | null>(null)
|
||||
export function requestMembersSort(field: 'credits' | 'lastActivity') {
|
||||
pendingSort.value = field
|
||||
}
|
||||
|
||||
export function sortMembers(
|
||||
members: WorkspaceMember[],
|
||||
currentUserEmail: string | null,
|
||||
@@ -201,8 +208,9 @@ export function useMembersPanel() {
|
||||
|
||||
const searchQuery = ref('')
|
||||
const activeView = ref<ActiveView>('active')
|
||||
const sortField = ref<SortField>('inviteDate')
|
||||
const sortField = ref<SortField>(pendingSort.value ?? 'inviteDate')
|
||||
const sortDirection = ref<SortDirection>('desc')
|
||||
pendingSort.value = null
|
||||
|
||||
function roleMenuItem(
|
||||
member: WorkspaceMember,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
// TODO(billing-history endpoint): the workspace billing API does not expose
|
||||
// the upcoming invoice amount yet; the Invoices banner stays hidden until it
|
||||
// does. Invoice history itself lives in the Stripe portal by design.
|
||||
export function useWorkspaceInvoices() {
|
||||
const nextInvoiceCents = computed<number | null>(() => null)
|
||||
return { nextInvoiceCents }
|
||||
}
|
||||
87
src/platform/workspace/composables/useWorkspaceOverview.ts
Normal file
87
src/platform/workspace/composables/useWorkspaceOverview.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
|
||||
import type { WorkspaceMember } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { userBadgeColor } from '@/platform/workspace/utils/badgeColor'
|
||||
import { formatRelativeTime } from '@/platform/workspace/utils/relativeTime'
|
||||
|
||||
export interface SnapshotRow {
|
||||
userName: string
|
||||
color: string
|
||||
lastActivity: string
|
||||
credits: number
|
||||
}
|
||||
|
||||
// Upper bound on the snapshot pool; the tile shows as many of these as its
|
||||
// height allows (see visibleSnapshotRows in WorkspaceOverviewContent).
|
||||
const SNAPSHOT_SIZE = 6
|
||||
|
||||
export function useWorkspaceOverview() {
|
||||
const { t, d } = useI18n()
|
||||
const store = useTeamWorkspaceStore()
|
||||
const { members } = storeToRefs(store)
|
||||
const { subscription, renewalDate } = useBillingContext()
|
||||
const { allowanceTotalCredits } = useSubscriptionCredits()
|
||||
|
||||
const renewalLabel = computed(() => {
|
||||
const raw = renewalDate.value
|
||||
if (!raw) return '—'
|
||||
return d(new Date(raw), { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
})
|
||||
|
||||
// The enterprise tier relabels the same team layout. 'ENTERPRISE' is a wire
|
||||
// tier not yet in the generated SubscriptionTier union, hence the cast.
|
||||
const plan = computed(() => ({
|
||||
name:
|
||||
(subscription.value?.tier as string | null) === 'ENTERPRISE'
|
||||
? 'Enterprise'
|
||||
: 'Team',
|
||||
monthlyCredits: allowanceTotalCredits.value ?? 0,
|
||||
renewalLabel: renewalLabel.value
|
||||
}))
|
||||
|
||||
function activityLabel(member: WorkspaceMember): string {
|
||||
if (!member.lastActivity) return '—'
|
||||
return formatRelativeTime(member.lastActivity, new Date(), {
|
||||
justNow: t('workspacePanel.members.activity.justNow'),
|
||||
minutesAgo: (n) => t('workspacePanel.members.activity.minutesAgo', { n }),
|
||||
hoursAgo: (n) => t('workspacePanel.members.activity.hoursAgo', { n }),
|
||||
daysAgo: (n) => t('workspacePanel.members.activity.daysAgo', n)
|
||||
})
|
||||
}
|
||||
|
||||
function toRow(member: WorkspaceMember): SnapshotRow {
|
||||
return {
|
||||
userName: member.name,
|
||||
color: userBadgeColor(member.name || member.email),
|
||||
lastActivity: activityLabel(member),
|
||||
credits: member.creditsUsedThisMonth ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
const topSpenders = computed(() =>
|
||||
[...members.value]
|
||||
.sort(
|
||||
(a, b) => (b.creditsUsedThisMonth ?? 0) - (a.creditsUsedThisMonth ?? 0)
|
||||
)
|
||||
.slice(0, SNAPSHOT_SIZE)
|
||||
.map(toRow)
|
||||
)
|
||||
|
||||
const recentActivity = computed(() =>
|
||||
[...members.value]
|
||||
.filter((member) => member.lastActivity)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(b.lastActivity?.getTime() ?? 0) - (a.lastActivity?.getTime() ?? 0)
|
||||
)
|
||||
.slice(0, SNAPSHOT_SIZE)
|
||||
.map(toRow)
|
||||
)
|
||||
|
||||
return { plan, topSpenders, recentActivity }
|
||||
}
|
||||
Reference in New Issue
Block a user