mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 17:28:58 +00:00
Compare commits
2 Commits
split/plan
...
split/memb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0c5775f49 | ||
|
|
f5c5597d70 |
@@ -54,11 +54,6 @@ const config: KnipConfig = {
|
||||
'.github/workflows/ci-oss-assets-validation.yaml',
|
||||
// Pending integration in stacked PR
|
||||
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
|
||||
// Pending integration in the workspace-settings stacked PRs: consumed by
|
||||
// split/auto-reload + split/allowlist (Switch) and split/member-auditing
|
||||
// + split/allowlist (Pagination); each consumer removes its entry
|
||||
'src/components/ui/switch/Switch.vue',
|
||||
'src/components/ui/pagination/Pagination.vue',
|
||||
// Marketing media tooling — adopted by pages in a follow-up PR
|
||||
'apps/website/src/components/common/SiteVideo.vue',
|
||||
'apps/website/src/utils/marketingImage.ts',
|
||||
|
||||
22
src/components/ui/hover-card/HoverCard.vue
Normal file
22
src/components/ui/hover-card/HoverCard.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { HoverCardRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
import type { HoverCardRootEmits, HoverCardRootProps } from 'reka-ui'
|
||||
import { provide, ref } from 'vue'
|
||||
|
||||
import { hoverCardOpenKey } from './hoverCardContext'
|
||||
|
||||
// eslint-disable-next-line vue/no-unused-properties -- forwarded to Reka via useForwardPropsEmits
|
||||
const props = defineProps<HoverCardRootProps>()
|
||||
const emits = defineEmits<HoverCardRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
|
||||
const isOpen = ref(false)
|
||||
provide(hoverCardOpenKey, isOpen)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HoverCardRoot v-bind="forwarded" v-model:open="isOpen">
|
||||
<slot />
|
||||
</HoverCardRoot>
|
||||
</template>
|
||||
51
src/components/ui/hover-card/HoverCardContent.vue
Normal file
51
src/components/ui/hover-card/HoverCardContent.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { ZIndex } from '@primeuix/utils/zindex'
|
||||
import { HoverCardContent, HoverCardPortal, useForwardProps } from 'reka-ui'
|
||||
import type { HoverCardContentProps } from 'reka-ui'
|
||||
import { computed, inject } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { hoverCardOpenKey } from './hoverCardContext'
|
||||
|
||||
// Shared base for @primeuix's auto-incrementing 'modal' z-index counter.
|
||||
const MODAL_BASE_Z_INDEX = 1700
|
||||
|
||||
const {
|
||||
class: className,
|
||||
side = 'bottom',
|
||||
sideOffset = 8,
|
||||
...rest
|
||||
} = defineProps<HoverCardContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const forwarded = useForwardProps(computed(() => rest))
|
||||
|
||||
// Body-portaled content sits at a static z-1700 unless a dialog that joined
|
||||
// @primeuix's 'modal' counter is open above it; then lift past that dialog.
|
||||
const open = inject(hoverCardOpenKey, undefined)
|
||||
const contentStyle = computed(() => {
|
||||
if (!open?.value) return undefined
|
||||
const topZIndex = ZIndex.getCurrent('modal')
|
||||
return topZIndex >= MODAL_BASE_Z_INDEX ? { zIndex: topZIndex + 1 } : undefined
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HoverCardPortal>
|
||||
<HoverCardContent
|
||||
v-bind="forwarded"
|
||||
:side
|
||||
:side-offset
|
||||
:style="contentStyle"
|
||||
:class="
|
||||
cn(
|
||||
'z-1700 rounded-lg border border-border-subtle bg-secondary-background p-2.5 shadow-md outline-none',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</HoverCardContent>
|
||||
</HoverCardPortal>
|
||||
</template>
|
||||
12
src/components/ui/hover-card/HoverCardTrigger.vue
Normal file
12
src/components/ui/hover-card/HoverCardTrigger.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { HoverCardTrigger } from 'reka-ui'
|
||||
import type { HoverCardTriggerProps } from 'reka-ui'
|
||||
|
||||
const props = defineProps<HoverCardTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HoverCardTrigger v-bind="props">
|
||||
<slot />
|
||||
</HoverCardTrigger>
|
||||
</template>
|
||||
7
src/components/ui/hover-card/hoverCardContext.ts
Normal file
7
src/components/ui/hover-card/hoverCardContext.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
|
||||
// Shares the root open-state with the content so it can lift its z-index above
|
||||
// a dialog that joined @primeuix's incrementing 'modal' counter (otherwise the
|
||||
// body-portaled content renders behind the settings dialog).
|
||||
export const hoverCardOpenKey: InjectionKey<Ref<boolean>> =
|
||||
Symbol('hoverCardOpen')
|
||||
@@ -3068,8 +3068,62 @@
|
||||
"planCredits": {
|
||||
"tabs": {
|
||||
"invoices": "Invoices",
|
||||
"overview": "Credits"
|
||||
"overview": "Credits",
|
||||
"activity": "Activity"
|
||||
}
|
||||
},
|
||||
"autoReload": {
|
||||
"badge": {
|
||||
"off": "Off",
|
||||
"paused": "Paused"
|
||||
},
|
||||
"dialog": {
|
||||
"allowsReloads": "Allows {count} reload /mo | Allows {count} reloads /mo",
|
||||
"amountLabel": "Add this amount of credits:",
|
||||
"budgetPlaceholderCredits": "Enter an amount of credits",
|
||||
"budgetPlaceholderUsd": "Enter an amount of dollars",
|
||||
"budgetToggleHint": "Limit how much is auto-reloaded per month",
|
||||
"budgetToggleLabel": "Monthly budget",
|
||||
"cancel": "Cancel",
|
||||
"minReload": "Minimum amount is {amount}",
|
||||
"thresholdLabel": "When credits drop below:",
|
||||
"title": "Auto-reload credits",
|
||||
"update": "Update"
|
||||
},
|
||||
"disabled": "Disabled",
|
||||
"edit": "Edit",
|
||||
"empty": {
|
||||
"body": "Keep your workflows running with auto-reloaded credits. Set a monthly budget so charges don't surprise you.",
|
||||
"cta": "Set up auto-reload"
|
||||
},
|
||||
"enabled": "Enabled",
|
||||
"subtitle": "Automatically add credits when your balance runs low, within an optional monthly budget.",
|
||||
"tile": {
|
||||
"label": "Auto-reload",
|
||||
"monthlyBudget": "Monthly budget",
|
||||
"percentSpent": "{percent}% spent",
|
||||
"spentOfBudget": "{spent} of {budget}",
|
||||
"whenBelow": "when credits drop below"
|
||||
},
|
||||
"title": "Credit auto-reload"
|
||||
},
|
||||
"activity": {
|
||||
"columns": {
|
||||
"creditsUsed": "Credits",
|
||||
"date": "Date",
|
||||
"eventDetails": "Event details",
|
||||
"eventType": "Event type",
|
||||
"user": "User"
|
||||
},
|
||||
"fullActivity": "Full activity",
|
||||
"hoverCard": {
|
||||
"lastActivity": "Last activity",
|
||||
"partnerNodeUsed": "Partner node used",
|
||||
"totalCreditsUsed": "Total credits used"
|
||||
},
|
||||
"perUserHint": "Looking for total usage per user?",
|
||||
"seeMembers": "See Members",
|
||||
"empty": "No activity yet."
|
||||
}
|
||||
},
|
||||
"teamWorkspacesDialog": {
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-132 max-w-full flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<h2 class="m-0 text-sm font-normal text-base-foreground">
|
||||
{{ $t('workspacePanel.autoReload.dialog.title') }}
|
||||
</h2>
|
||||
<button
|
||||
class="cursor-pointer rounded-sm border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="onClose"
|
||||
>
|
||||
<i class="pi pi-times size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label
|
||||
for="auto-reload-threshold"
|
||||
class="text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('workspacePanel.autoReload.dialog.thresholdLabel') }}
|
||||
</label>
|
||||
<div :class="fieldClass">
|
||||
<i class="icon-[lucide--coins] size-4 shrink-0 text-credit" />
|
||||
<input
|
||||
id="auto-reload-threshold"
|
||||
v-model="thresholdModel"
|
||||
inputmode="numeric"
|
||||
class="w-full min-w-0 border-none bg-transparent text-sm text-base-foreground tabular-nums outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="auto-reload-amount" class="text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.autoReload.dialog.amountLabel') }}
|
||||
</label>
|
||||
<div
|
||||
:class="cn(fieldClass, reloadBelowMinimum && 'ring-1 ring-red-500')"
|
||||
>
|
||||
<i
|
||||
v-if="unit === 'credits'"
|
||||
class="icon-[lucide--coins] size-4 shrink-0 text-credit"
|
||||
/>
|
||||
<span v-else class="shrink-0 text-sm text-muted-foreground">$</span>
|
||||
<input
|
||||
id="auto-reload-amount"
|
||||
v-model="reloadModel"
|
||||
inputmode="numeric"
|
||||
class="w-full min-w-0 border-none bg-transparent text-sm text-base-foreground tabular-nums outline-none"
|
||||
/>
|
||||
<span
|
||||
class="flex shrink-0 items-center gap-1 text-sm text-muted-foreground tabular-nums"
|
||||
>
|
||||
<template v-if="unit === 'credits'"
|
||||
>≈ {{ reloadCostLabel }}</template
|
||||
>
|
||||
<template v-else>
|
||||
≈
|
||||
<i class="icon-[lucide--coins] size-3.5 text-muted-foreground" />
|
||||
{{ reloadCreditsLabel }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="reloadError" class="m-0 text-xs text-red-500">
|
||||
{{ reloadError }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 border-t border-border-default p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span
|
||||
id="auto-reload-budget-label"
|
||||
class="text-sm font-medium text-base-foreground"
|
||||
>
|
||||
{{ $t('workspacePanel.autoReload.dialog.budgetToggleLabel') }}
|
||||
</span>
|
||||
<span class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
{{
|
||||
budgetEnabled
|
||||
? $t('workspacePanel.autoReload.enabled')
|
||||
: $t('workspacePanel.autoReload.disabled')
|
||||
}}
|
||||
<Switch v-model="budgetEnabled" />
|
||||
</span>
|
||||
</div>
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.autoReload.dialog.budgetToggleHint') }}
|
||||
</p>
|
||||
<div :class="cn(fieldClass, !budgetEnabled && 'opacity-50')">
|
||||
<i
|
||||
v-if="unit === 'credits'"
|
||||
class="icon-[lucide--coins] size-4 shrink-0 text-credit"
|
||||
/>
|
||||
<span v-else class="shrink-0 text-sm text-muted-foreground">$</span>
|
||||
<input
|
||||
v-model="budgetModel"
|
||||
:disabled="!budgetEnabled"
|
||||
aria-labelledby="auto-reload-budget-label"
|
||||
inputmode="numeric"
|
||||
:placeholder="budgetPlaceholder"
|
||||
class="w-full min-w-0 border-none bg-transparent text-sm text-base-foreground tabular-nums outline-none disabled:cursor-not-allowed"
|
||||
/>
|
||||
<span
|
||||
v-if="budgetEnabled && budgetCents > 0"
|
||||
class="flex shrink-0 items-center gap-1 text-sm text-muted-foreground tabular-nums"
|
||||
>
|
||||
<template v-if="unit === 'credits'">≈ {{ budgetUsdLabel }}</template>
|
||||
<template v-else>
|
||||
≈
|
||||
<i class="icon-[lucide--coins] size-3.5 text-muted-foreground" />
|
||||
{{ budgetCreditsLabel }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="budgetEnabled && budgetCents > 0"
|
||||
class="m-0 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ allowsReloadsLabel }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-border-default p-4"
|
||||
>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
:model-value="unit"
|
||||
class="rounded-lg bg-secondary-background p-0.5"
|
||||
@update:model-value="onUnitChange"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
v-for="option in unitOptions"
|
||||
:key="option"
|
||||
:value="option"
|
||||
size="lg"
|
||||
>
|
||||
{{ $t(`workspacePanel.autoReload.dialog.${option}`) }}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<div class="flex items-center gap-4">
|
||||
<Button variant="muted-textonly" @click="onClose">
|
||||
{{ $t('workspacePanel.autoReload.dialog.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:disabled="!canUpdate"
|
||||
@click="onUpdate"
|
||||
>
|
||||
{{ $t('workspacePanel.autoReload.dialog.update') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
centsToCredits,
|
||||
creditsToCents,
|
||||
creditsToUsd,
|
||||
usdToCents,
|
||||
usdToCredits
|
||||
} from '@/base/credits/comfyCredits'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Switch from '@/components/ui/switch/Switch.vue'
|
||||
import ToggleGroup from '@/components/ui/toggle-group/ToggleGroup.vue'
|
||||
import ToggleGroupItem from '@/components/ui/toggle-group/ToggleGroupItem.vue'
|
||||
import { useAutoReload } from '@/platform/workspace/composables/useAutoReload'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { t, n: fmtNumber } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
const { config, save } = useAutoReload()
|
||||
|
||||
type Unit = 'credits' | 'usd'
|
||||
const unitOptions: Unit[] = ['credits', 'usd']
|
||||
const unit = ref<Unit>('credits')
|
||||
|
||||
// reka's single ToggleGroup can emit '' on re-click (deselect); ignore that so a
|
||||
// unit always stays selected.
|
||||
function onUnitChange(value: unknown) {
|
||||
if (value === 'credits' || value === 'usd') unit.value = value
|
||||
}
|
||||
|
||||
const thresholdCredits = ref(config.thresholdCredits ?? 1000)
|
||||
const reloadCredits = ref(config.reloadCredits ?? 5000)
|
||||
const budgetEnabled = ref(config.monthlyBudgetCents != null)
|
||||
const budgetCents = ref(config.monthlyBudgetCents ?? 0)
|
||||
|
||||
const fieldClass =
|
||||
'flex items-center gap-2 rounded-lg bg-secondary-background px-3 py-2.5'
|
||||
|
||||
const fmtInt = (value: number) => fmtNumber(value, { maximumFractionDigits: 0 })
|
||||
const fmtUsd = (cents: number) =>
|
||||
fmtNumber(cents / 100, {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0
|
||||
})
|
||||
const parseNum = (raw: string) => {
|
||||
const parsed = Number(raw.replace(/[^0-9.]/g, ''))
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
|
||||
// A 0 value renders as an empty field (not "0") so backspacing clears it.
|
||||
const thresholdModel = computed({
|
||||
get: () =>
|
||||
thresholdCredits.value === 0 ? '' : fmtInt(thresholdCredits.value),
|
||||
set: (value) => (thresholdCredits.value = Math.round(parseNum(value)))
|
||||
})
|
||||
|
||||
const reloadModel = computed({
|
||||
get: () => {
|
||||
if (reloadCredits.value === 0) return ''
|
||||
return unit.value === 'credits'
|
||||
? fmtInt(reloadCredits.value)
|
||||
: fmtInt(creditsToUsd(reloadCredits.value))
|
||||
},
|
||||
set: (value) => {
|
||||
const parsed = parseNum(value)
|
||||
reloadCredits.value =
|
||||
unit.value === 'credits' ? Math.round(parsed) : usdToCredits(parsed)
|
||||
}
|
||||
})
|
||||
const reloadCostLabel = computed(() =>
|
||||
fmtUsd(creditsToCents(reloadCredits.value))
|
||||
)
|
||||
const reloadCreditsLabel = computed(() => fmtInt(reloadCredits.value))
|
||||
|
||||
const budgetModel = computed({
|
||||
get: () => {
|
||||
if (budgetCents.value === 0) return ''
|
||||
return unit.value === 'credits'
|
||||
? fmtInt(centsToCredits(budgetCents.value))
|
||||
: fmtInt(Math.round(budgetCents.value / 100))
|
||||
},
|
||||
set: (value) => {
|
||||
const parsed = parseNum(value)
|
||||
budgetCents.value =
|
||||
unit.value === 'credits'
|
||||
? creditsToCents(Math.round(parsed))
|
||||
: usdToCents(parsed)
|
||||
}
|
||||
})
|
||||
const budgetUsdLabel = computed(() => fmtUsd(budgetCents.value))
|
||||
const budgetCreditsLabel = computed(() =>
|
||||
fmtInt(centsToCredits(budgetCents.value))
|
||||
)
|
||||
const budgetPlaceholder = computed(() =>
|
||||
unit.value === 'credits'
|
||||
? t('workspacePanel.autoReload.dialog.budgetPlaceholderCredits')
|
||||
: t('workspacePanel.autoReload.dialog.budgetPlaceholderUsd')
|
||||
)
|
||||
|
||||
const allowsReloadsLabel = computed(() => {
|
||||
const reloads =
|
||||
reloadCredits.value > 0
|
||||
? Math.floor(centsToCredits(budgetCents.value) / reloadCredits.value)
|
||||
: 0
|
||||
return t('workspacePanel.autoReload.dialog.allowsReloads', reloads)
|
||||
})
|
||||
|
||||
// The reload amount must be worth at least $5 (its credit equivalent).
|
||||
const MIN_RELOAD_CENTS = 500
|
||||
const MIN_RELOAD_CREDITS = usdToCredits(5)
|
||||
|
||||
const reloadBelowMinimum = computed(
|
||||
() => reloadCredits.value > 0 && reloadCredits.value < MIN_RELOAD_CREDITS
|
||||
)
|
||||
const reloadError = computed(() => {
|
||||
if (!reloadBelowMinimum.value) return ''
|
||||
const amount =
|
||||
unit.value === 'credits'
|
||||
? fmtInt(MIN_RELOAD_CREDITS)
|
||||
: fmtUsd(MIN_RELOAD_CENTS)
|
||||
return t('workspacePanel.autoReload.dialog.minReload', { amount })
|
||||
})
|
||||
|
||||
const canUpdate = computed(
|
||||
() =>
|
||||
thresholdCredits.value > 0 &&
|
||||
reloadCredits.value >= MIN_RELOAD_CREDITS &&
|
||||
(!budgetEnabled.value || budgetCents.value > 0)
|
||||
)
|
||||
|
||||
function onClose() {
|
||||
dialogStore.closeDialog({ key: 'auto-reload' })
|
||||
}
|
||||
|
||||
function onUpdate() {
|
||||
if (!canUpdate.value) return
|
||||
save({
|
||||
thresholdCredits: thresholdCredits.value,
|
||||
reloadCredits: reloadCredits.value,
|
||||
monthlyBudgetCents:
|
||||
budgetEnabled.value && budgetCents.value > 0 ? budgetCents.value : null
|
||||
})
|
||||
onClose()
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,205 @@
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col gap-4 rounded-2xl border border-interface-stroke/60 p-6 transition-opacity',
|
||||
// A lapsed plan can't auto-reload, so freeze the whole section: dim it,
|
||||
// block interaction, and force the toggle to read Disabled.
|
||||
frozen && 'pointer-events-none opacity-50'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div
|
||||
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 font-medium text-base-foreground">
|
||||
{{ $t('workspacePanel.autoReload.title') }}
|
||||
</span>
|
||||
<span class="max-w-md text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.autoReload.subtitle') }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="isConfigured" class="flex shrink-0 items-center gap-3">
|
||||
<span class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
{{ enabledLabel }}
|
||||
<Switch
|
||||
:model-value="displayEnabled"
|
||||
@update:model-value="setEnabled"
|
||||
/>
|
||||
</span>
|
||||
<Button variant="secondary" size="lg" @click="openConfig">
|
||||
{{ $t('workspacePanel.autoReload.edit') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty / not-set-up state — same one-column grid as the configured tile
|
||||
so both sit at the top tiles' width. -->
|
||||
<div v-if="!isConfigured" class="grid grid-cols-1 gap-4 @4xl:grid-cols-2">
|
||||
<div
|
||||
class="flex flex-col gap-3 rounded-xl bg-modal-panel-background px-6 py-5"
|
||||
>
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.autoReload.empty.body') }}
|
||||
</p>
|
||||
<Button variant="tertiary" size="lg" class="w-full" @click="openConfig">
|
||||
{{ $t('workspacePanel.autoReload.empty.cta') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configured tile — constrained to one column of the same grid the top
|
||||
tiles use, so it matches their width (empty second column left open). -->
|
||||
<div v-else class="grid grid-cols-1 gap-4 @4xl:grid-cols-2">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col gap-4 rounded-xl bg-modal-panel-background px-6 py-5 transition-opacity',
|
||||
// Skip this dim when frozen — the section root already dims uniformly.
|
||||
!frozen && !isEnabled && 'opacity-50'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.autoReload.tile.label') }}
|
||||
</span>
|
||||
<StatusBadge v-if="badge" :label="badge" :severity="badgeSeverity" />
|
||||
</div>
|
||||
|
||||
<p
|
||||
:class="
|
||||
cn(
|
||||
'm-0 flex items-center gap-1.5 text-sm text-muted-foreground',
|
||||
isPaused && 'opacity-50'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--coins] size-4 text-credit" />
|
||||
<span
|
||||
class="text-2xl leading-none font-semibold text-base-foreground tabular-nums"
|
||||
>
|
||||
{{ reloadCreditsLabel }}
|
||||
</span>
|
||||
{{ $t('workspacePanel.autoReload.tile.whenBelow') }}
|
||||
<span class="font-semibold text-base-foreground tabular-nums">
|
||||
{{ thresholdLabel }}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<template v-if="hasBudget">
|
||||
<div class="h-px w-full bg-interface-stroke" />
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-muted-foreground">
|
||||
{{ $t('workspacePanel.autoReload.tile.monthlyBudget') }}
|
||||
</span>
|
||||
<span :class="cn('tabular-nums', percentSpentClass)">
|
||||
{{ percentSpentLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar :value="budgetUsedFraction" />
|
||||
<div class="flex justify-end text-sm">
|
||||
<span class="text-muted-foreground tabular-nums">
|
||||
{{ budgetSpentLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import StatusBadge from '@/components/common/StatusBadge.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Switch from '@/components/ui/switch/Switch.vue'
|
||||
import ProgressBar from '@/platform/workspace/components/dialogs/settings/ProgressBar.vue'
|
||||
import { useAutoReload } from '@/platform/workspace/composables/useAutoReload'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { frozen = false } = defineProps<{
|
||||
/**
|
||||
* The plan can't spend (lapsed or paused): render the whole section dimmed,
|
||||
* off, and non-interactive.
|
||||
*/
|
||||
frozen?: boolean
|
||||
}>()
|
||||
|
||||
const { t, n: fmtNumber } = useI18n()
|
||||
|
||||
const {
|
||||
config,
|
||||
isConfigured,
|
||||
isEnabled,
|
||||
hasBudget,
|
||||
budgetUsedFraction,
|
||||
isPaused,
|
||||
isWarning,
|
||||
setEnabled
|
||||
} = useAutoReload()
|
||||
|
||||
const displayEnabled = computed(() => !frozen && isEnabled.value)
|
||||
|
||||
const { showAutoReloadDialog } = useDialogService()
|
||||
|
||||
function openConfig() {
|
||||
void showAutoReloadDialog()
|
||||
}
|
||||
|
||||
const fmtCredits = (value: number) =>
|
||||
fmtNumber(value, { maximumFractionDigits: 0 })
|
||||
const fmtUsd = (cents: number) =>
|
||||
fmtNumber(cents / 100, {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0
|
||||
})
|
||||
|
||||
const enabledLabel = computed(() =>
|
||||
displayEnabled.value
|
||||
? t('workspacePanel.autoReload.enabled')
|
||||
: t('workspacePanel.autoReload.disabled')
|
||||
)
|
||||
|
||||
const badge = computed(() => {
|
||||
if (frozen) return t('workspacePanel.autoReload.badge.off')
|
||||
if (isPaused.value) return t('workspacePanel.autoReload.badge.paused')
|
||||
if (!isEnabled.value) return t('workspacePanel.autoReload.badge.off')
|
||||
return ''
|
||||
})
|
||||
|
||||
// Paused is an alert state, so give it the high-contrast (inverted) pill; the
|
||||
// quieter "off" state keeps the secondary treatment.
|
||||
const badgeSeverity = computed(() =>
|
||||
isPaused.value ? 'contrast' : 'secondary'
|
||||
)
|
||||
|
||||
const reloadCreditsLabel = computed(() => fmtCredits(config.reloadCredits))
|
||||
const thresholdLabel = computed(() => fmtCredits(config.thresholdCredits))
|
||||
|
||||
const percentSpentLabel = computed(() =>
|
||||
t('workspacePanel.autoReload.tile.percentSpent', {
|
||||
percent: Math.round(budgetUsedFraction.value * 100)
|
||||
})
|
||||
)
|
||||
const percentSpentClass = computed(() =>
|
||||
isPaused.value
|
||||
? 'text-danger'
|
||||
: isWarning.value
|
||||
? 'text-credit'
|
||||
: 'text-muted-foreground'
|
||||
)
|
||||
const budgetSpentLabel = computed(() =>
|
||||
t('workspacePanel.autoReload.tile.spentOfBudget', {
|
||||
spent: fmtUsd(config.spentThisCycleCents),
|
||||
budget: fmtUsd(config.monthlyBudgetCents ?? 0)
|
||||
})
|
||||
)
|
||||
</script>
|
||||
@@ -3,22 +3,33 @@
|
||||
: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.
|
||||
// The panel runs flush to the bottom edge (BaseModalLayout 'flush'); the
|
||||
// Activity/Invoices tables keep their 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'"
|
||||
<div
|
||||
class="flex w-full flex-col gap-3 @2xl:flex-row @2xl:items-center @2xl:gap-9"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 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>
|
||||
<SearchInput
|
||||
v-if="activeView === 'activity'"
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('g.search')"
|
||||
size="lg"
|
||||
@click="setView(tab.key)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</Button>
|
||||
class="w-full @2xl:w-64"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BillingStatusBanner>
|
||||
@@ -35,7 +46,14 @@
|
||||
</template>
|
||||
</BillingStatusBanner>
|
||||
|
||||
<WorkspaceOverviewContent v-if="activeView === 'overview'" />
|
||||
<WorkspaceOverviewContent
|
||||
v-if="activeView === 'overview'"
|
||||
@navigate="setView"
|
||||
/>
|
||||
<WorkspaceActivityContent
|
||||
v-else-if="activeView === 'activity'"
|
||||
:search="searchQuery"
|
||||
/>
|
||||
<WorkspaceInvoicesContent v-else />
|
||||
</div>
|
||||
</template>
|
||||
@@ -44,15 +62,17 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import BillingStatusBanner from '@/platform/workspace/components/dialogs/settings/BillingStatusBanner.vue'
|
||||
import WorkspaceActivityContent from '@/platform/workspace/components/dialogs/settings/WorkspaceActivityContent.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'
|
||||
type View = 'overview' | 'activity' | 'invoices'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -65,7 +85,8 @@ function openInvoiceHistory() {
|
||||
|
||||
const tabs = computed(() => {
|
||||
const base: { key: View; label: string }[] = [
|
||||
{ key: 'overview', label: t('workspacePanel.planCredits.tabs.overview') }
|
||||
{ key: 'overview', label: t('workspacePanel.planCredits.tabs.overview') },
|
||||
{ key: 'activity', label: t('workspacePanel.planCredits.tabs.activity') }
|
||||
]
|
||||
// Invoices are billing details — owners/admins only.
|
||||
if (permissions.value.canManageSubscription) {
|
||||
@@ -77,8 +98,10 @@ const tabs = computed(() => {
|
||||
return base
|
||||
})
|
||||
const activeView = ref<View>('overview')
|
||||
const searchQuery = ref('')
|
||||
|
||||
function setView(view: View) {
|
||||
activeView.value = view
|
||||
searchQuery.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<div
|
||||
class="h-2 w-full overflow-hidden rounded-full bg-secondary-background-hover"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full bg-credit transition-[width]"
|
||||
:style="{ width: `${clamped * 100}%` }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { value } = defineProps<{ value: number }>()
|
||||
|
||||
const clamped = computed(() => Math.min(1, Math.max(0, value)))
|
||||
</script>
|
||||
@@ -0,0 +1,315 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-4">
|
||||
<div
|
||||
ref="tableContainer"
|
||||
class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-interface-stroke/60"
|
||||
>
|
||||
<Table class="min-h-0 flex-1 px-4">
|
||||
<TableHeader class="sticky top-0 z-10 bg-base-background">
|
||||
<TableRow
|
||||
class="hover:bg-transparent [&>th]:h-14 [&>th]:border-b [&>th]:border-interface-stroke/60"
|
||||
>
|
||||
<TableHead class="w-40" :aria-sort="ariaSort('date')">
|
||||
<button :class="sortHeaderClass" @click="toggleSort('date')">
|
||||
{{ $t('workspacePanel.activity.columns.date') }}
|
||||
<i :class="sortIcon('date')" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead class="w-56" :aria-sort="ariaSort('user')">
|
||||
<button :class="sortHeaderClass" @click="toggleSort('user')">
|
||||
{{ $t('workspacePanel.activity.columns.user') }}
|
||||
<i :class="sortIcon('user')" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead :aria-sort="ariaSort('eventType')">
|
||||
<button :class="sortHeaderClass" @click="toggleSort('eventType')">
|
||||
{{ $t('workspacePanel.activity.columns.eventType') }}
|
||||
<i :class="sortIcon('eventType')" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead class="w-32" :aria-sort="ariaSort('detail')">
|
||||
<button :class="sortHeaderClass" @click="toggleSort('detail')">
|
||||
{{ $t('workspacePanel.activity.columns.eventDetails') }}
|
||||
<i :class="sortIcon('detail')" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead class="w-40" :aria-sort="ariaSort('credits')">
|
||||
<button
|
||||
:class="cn(sortHeaderClass, 'ml-auto')"
|
||||
@click="toggleSort('credits')"
|
||||
>
|
||||
<i class="icon-[lucide--coins] size-4" />
|
||||
{{ $t('workspacePanel.activity.columns.creditsUsed') }}
|
||||
<i :class="sortIcon('credits')" />
|
||||
</button>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="event in pagedItems"
|
||||
:key="event.id"
|
||||
class="hover:bg-transparent [&:last-child>td]:border-b-0 [&>td]:border-b [&>td]:border-interface-stroke/20"
|
||||
>
|
||||
<TableCell class="text-sm text-muted-foreground tabular-nums">
|
||||
{{ formatDate(event.date) }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<HoverCard
|
||||
v-if="event.userName"
|
||||
:open-delay="150"
|
||||
:close-delay="0"
|
||||
>
|
||||
<HoverCardTrigger
|
||||
as="div"
|
||||
class="flex w-fit cursor-default items-center gap-3"
|
||||
>
|
||||
<span
|
||||
class="flex size-5 shrink-0 items-center justify-center rounded-full"
|
||||
:style="{ backgroundColor: userBadgeColor(event.userName) }"
|
||||
>
|
||||
<span class="text-2xs font-bold text-base-foreground">
|
||||
{{ event.userName.charAt(0).toUpperCase() }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="truncate text-sm text-base-foreground">
|
||||
{{ event.userName }}
|
||||
</span>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent class="w-64" align="start">
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div class="flex h-5 items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{
|
||||
$t(
|
||||
'workspacePanel.activity.hoverCard.totalCreditsUsed'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<i
|
||||
class="icon-[lucide--coins] size-4 text-muted-foreground"
|
||||
/>
|
||||
<span class="text-sm text-base-foreground tabular-nums">
|
||||
{{
|
||||
summaryFor(
|
||||
event.userName
|
||||
).totalCredits.toLocaleString()
|
||||
}}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex h-5 items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{
|
||||
$t('workspacePanel.activity.hoverCard.lastActivity')
|
||||
}}
|
||||
</span>
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ lastActivityLabel(event.userName) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
<span v-else class="text-sm text-muted-foreground">—</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-sm text-muted-foreground">
|
||||
<HoverCard
|
||||
v-if="event.partnerNode"
|
||||
:open-delay="150"
|
||||
:close-delay="0"
|
||||
>
|
||||
<HoverCardTrigger as="span" class="cursor-default">
|
||||
{{ event.eventType }}
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent class="w-72" align="start">
|
||||
<div class="flex h-5 items-center justify-between gap-4">
|
||||
<span
|
||||
class="text-sm whitespace-nowrap text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
$t('workspacePanel.activity.hoverCard.partnerNodeUsed')
|
||||
}}
|
||||
</span>
|
||||
<span class="truncate text-sm text-base-foreground">
|
||||
{{ event.partnerNode }}
|
||||
</span>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
<template v-else>{{ event.eventType }}</template>
|
||||
</TableCell>
|
||||
<TableCell class="text-sm text-muted-foreground tabular-nums">
|
||||
{{ event.detail || '—' }}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
:class="
|
||||
cn(
|
||||
'text-right text-sm tabular-nums',
|
||||
event.credited ? 'text-credit' : 'text-muted-foreground'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ event.credited ? '+' : ''
|
||||
}}{{ event.credits.toLocaleString() }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-if="pagedItems.length === 0" class="hover:bg-transparent">
|
||||
<TableCell
|
||||
:colspan="5"
|
||||
class="py-6 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('workspacePanel.activity.empty') }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 @2xl:h-8 @2xl:flex-row @2xl:items-center">
|
||||
<div
|
||||
v-if="canViewTeamUsage"
|
||||
class="flex flex-wrap items-center gap-x-6 gap-y-2"
|
||||
>
|
||||
<a
|
||||
:href="fullActivityUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex cursor-pointer items-center gap-1 text-sm text-muted-foreground no-underline transition-colors hover:text-base-foreground"
|
||||
>
|
||||
<i class="icon-[lucide--external-link] size-4" />
|
||||
{{ $t('workspacePanel.activity.fullActivity') }}
|
||||
</a>
|
||||
<div class="flex items-center gap-3">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.activity.perUserHint') }}
|
||||
</p>
|
||||
<button
|
||||
class="flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 font-[inherit] text-sm text-base-foreground transition-colors hover:text-muted-foreground"
|
||||
@click="goToMembers"
|
||||
>
|
||||
{{ $t('workspacePanel.activity.seeMembers') }}
|
||||
<i class="icon-[lucide--arrow-right] size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Pagination
|
||||
v-model:page="page"
|
||||
:total="total"
|
||||
:items-per-page="itemsPerPage"
|
||||
class="@2xl:ml-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import HoverCard from '@/components/ui/hover-card/HoverCard.vue'
|
||||
import HoverCardContent from '@/components/ui/hover-card/HoverCardContent.vue'
|
||||
import HoverCardTrigger from '@/components/ui/hover-card/HoverCardTrigger.vue'
|
||||
import Pagination from '@/components/ui/pagination/Pagination.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 { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
|
||||
import { useSettingsNavigation } from '@/platform/settings/composables/useSettingsNavigation'
|
||||
import { useAutoPageSize } from '@/platform/workspace/composables/useAutoPageSize'
|
||||
import { requestMembersSort } from '@/platform/workspace/composables/useMembersPanel'
|
||||
import { useWorkspaceActivity } from '@/platform/workspace/composables/useWorkspaceActivity'
|
||||
import type { ActivitySortField } from '@/platform/workspace/composables/useWorkspaceActivity'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { userBadgeColor } from '@/platform/workspace/utils/badgeColor'
|
||||
import { formatRelativeTime } from '@/platform/workspace/utils/relativeTime'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { search } = defineProps<{ search: string }>()
|
||||
|
||||
const { t, d } = useI18n()
|
||||
|
||||
const tableContainer = ref<HTMLElement | null>(null)
|
||||
const { pageSize } = useAutoPageSize(tableContainer, 1)
|
||||
|
||||
// Owners/admins see team-wide activity + the per-user footer; members see only
|
||||
// their own usage, scoped to their name.
|
||||
const { permissions } = useWorkspaceUI()
|
||||
const { userDisplayName, userEmail } = useCurrentUser()
|
||||
const canViewTeamUsage = computed(() => permissions.value.canManageSubscription)
|
||||
const selfName = computed(() =>
|
||||
canViewTeamUsage.value
|
||||
? null
|
||||
: userDisplayName.value || userEmail.value || t('g.you')
|
||||
)
|
||||
|
||||
const fullActivityUrl = `${getComfyPlatformBaseUrl()}/profile/usage`
|
||||
|
||||
const { navigateToPanel } = useSettingsNavigation()
|
||||
|
||||
function goToMembers() {
|
||||
requestMembersSort('credits')
|
||||
navigateToPanel('workspace-members')
|
||||
}
|
||||
|
||||
const {
|
||||
page,
|
||||
total,
|
||||
itemsPerPage,
|
||||
pagedItems,
|
||||
sortField,
|
||||
sortDirection,
|
||||
toggleSort,
|
||||
userSummaries
|
||||
} = useWorkspaceActivity(() => search, pageSize, selfName)
|
||||
|
||||
function summaryFor(userName: string) {
|
||||
return (
|
||||
userSummaries.value.get(userName) ?? {
|
||||
totalCredits: 0,
|
||||
lastActivity: new Date()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function lastActivityLabel(userName: string): string {
|
||||
return formatRelativeTime(summaryFor(userName).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)
|
||||
})
|
||||
}
|
||||
|
||||
const sortHeaderClass =
|
||||
'flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left font-[inherit] text-sm text-muted-foreground'
|
||||
|
||||
function sortIcon(field: ActivitySortField) {
|
||||
if (sortField.value !== field) return 'icon-[lucide--chevrons-up-down] size-3'
|
||||
return sortDirection.value === 'asc'
|
||||
? 'icon-[lucide--chevron-up] size-3'
|
||||
: 'icon-[lucide--chevron-down] size-3'
|
||||
}
|
||||
|
||||
function ariaSort(
|
||||
field: ActivitySortField
|
||||
): 'ascending' | 'descending' | 'none' {
|
||||
if (sortField.value !== field) return 'none'
|
||||
return sortDirection.value === 'asc' ? 'ascending' : 'descending'
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return d(date, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -191,6 +191,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Credit auto-reload; frozen alongside the tile whenever the plan can't
|
||||
spend — lapsed or paused. -->
|
||||
<AutoReloadSection
|
||||
v-if="canManageBilling"
|
||||
:frozen="isInactive || isPaused"
|
||||
/>
|
||||
|
||||
<!-- mt-auto floats the footer to the panel's bottom edge; pb-6 (matching
|
||||
the other tabs) keeps it level with their footers. -->
|
||||
<div
|
||||
@@ -248,6 +255,7 @@ 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 AutoReloadSection from '@/platform/workspace/components/dialogs/settings/AutoReloadSection.vue'
|
||||
import { buildSupportUrl } from '@/platform/support/config'
|
||||
import { useAutoPageSize } from '@/platform/workspace/composables/useAutoPageSize'
|
||||
import { requestMembersSort } from '@/platform/workspace/composables/useMembersPanel'
|
||||
@@ -257,6 +265,8 @@ import { useWorkspaceOverview } from '@/platform/workspace/composables/useWorksp
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
const emit = defineEmits<{ navigate: [view: 'activity'] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Plan lifecycle actions are for the workspace creator (Owner) only; Admins and
|
||||
@@ -374,12 +384,14 @@ const snapshotEmptyMessage = computed(() => {
|
||||
: null
|
||||
})
|
||||
|
||||
// Both snapshot tabs deep-link to the Members panel (sorted by spend or
|
||||
// recency) until the Activity tab exists.
|
||||
// Top spenders → the Members panel pre-sorted by credit usage; Recent activity
|
||||
// → the Activity tab.
|
||||
function handleSeeMore() {
|
||||
requestMembersSort(
|
||||
snapshotView.value === 'recent' ? 'lastActivity' : 'credits'
|
||||
)
|
||||
if (snapshotView.value === 'recent') {
|
||||
emit('navigate', 'activity')
|
||||
return
|
||||
}
|
||||
requestMembersSort('credits')
|
||||
navigateToPanel('workspace-members')
|
||||
}
|
||||
</script>
|
||||
|
||||
96
src/platform/workspace/composables/useAutoReload.ts
Normal file
96
src/platform/workspace/composables/useAutoReload.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { computed, reactive } from 'vue'
|
||||
|
||||
import { creditsToCents } from '@/base/credits/comfyCredits'
|
||||
|
||||
interface AutoReloadConfig {
|
||||
configured: boolean
|
||||
enabled: boolean
|
||||
thresholdCredits: number
|
||||
reloadCredits: number
|
||||
// null = no monthly budget set
|
||||
monthlyBudgetCents: number | null
|
||||
spentThisCycleCents: number
|
||||
}
|
||||
|
||||
// TODO(auto-reload endpoint): there is no auto-reload API yet, so the config
|
||||
// is client-side state — it starts unconfigured and edits don't persist
|
||||
// across reloads.
|
||||
const config = reactive<AutoReloadConfig>({
|
||||
configured: false,
|
||||
enabled: false,
|
||||
thresholdCredits: 1000,
|
||||
reloadCredits: 5000,
|
||||
monthlyBudgetCents: null,
|
||||
spentThisCycleCents: 0
|
||||
})
|
||||
|
||||
export function useAutoReload() {
|
||||
const isConfigured = computed(() => config.configured)
|
||||
const isEnabled = computed(() => config.enabled)
|
||||
const hasBudget = computed(() => config.monthlyBudgetCents != null)
|
||||
|
||||
const reloadCostCents = computed(() => creditsToCents(config.reloadCredits))
|
||||
|
||||
const budgetLeftCents = computed(() =>
|
||||
config.monthlyBudgetCents == null
|
||||
? 0
|
||||
: Math.max(0, config.monthlyBudgetCents - config.spentThisCycleCents)
|
||||
)
|
||||
|
||||
const budgetUsedFraction = computed(() =>
|
||||
config.monthlyBudgetCents != null && config.monthlyBudgetCents > 0
|
||||
? Math.min(1, config.spentThisCycleCents / config.monthlyBudgetCents)
|
||||
: 0
|
||||
)
|
||||
|
||||
const reloadsLeft = computed(() =>
|
||||
hasBudget.value
|
||||
? Math.floor(budgetLeftCents.value / reloadCostCents.value)
|
||||
: null
|
||||
)
|
||||
|
||||
// Budget drained while still enabled → auto-reload can't fire, so it's paused.
|
||||
const isPaused = computed(
|
||||
() => config.enabled && hasBudget.value && budgetLeftCents.value <= 0
|
||||
)
|
||||
|
||||
// One reload (or none) of headroom left before the budget pauses it.
|
||||
const isWarning = computed(
|
||||
() =>
|
||||
config.enabled &&
|
||||
hasBudget.value &&
|
||||
!isPaused.value &&
|
||||
(reloadsLeft.value ?? Infinity) <= 1
|
||||
)
|
||||
|
||||
function setEnabled(value: boolean) {
|
||||
config.enabled = value
|
||||
}
|
||||
|
||||
function save(next: {
|
||||
thresholdCredits: number
|
||||
reloadCredits: number
|
||||
monthlyBudgetCents: number | null
|
||||
}) {
|
||||
config.configured = true
|
||||
config.enabled = true
|
||||
config.thresholdCredits = next.thresholdCredits
|
||||
config.reloadCredits = next.reloadCredits
|
||||
config.monthlyBudgetCents = next.monthlyBudgetCents
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
isConfigured,
|
||||
isEnabled,
|
||||
hasBudget,
|
||||
reloadCostCents,
|
||||
budgetLeftCents,
|
||||
budgetUsedFraction,
|
||||
reloadsLeft,
|
||||
isPaused,
|
||||
isWarning,
|
||||
setEnabled,
|
||||
save
|
||||
}
|
||||
}
|
||||
126
src/platform/workspace/composables/useWorkspaceActivity.ts
Normal file
126
src/platform/workspace/composables/useWorkspaceActivity.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import { computed, ref, toValue, watch } from 'vue'
|
||||
|
||||
export interface ActivityEvent {
|
||||
id: string
|
||||
date: Date
|
||||
userName: string
|
||||
eventType: string
|
||||
detail: string
|
||||
credits: number
|
||||
/** The partner node used, for 'Partner node usage' events. */
|
||||
partnerNode?: string
|
||||
/** True for credit inflows (auto-reload, top-up) vs. usage outflows. */
|
||||
credited?: boolean
|
||||
}
|
||||
|
||||
export interface UserSummary {
|
||||
totalCredits: number
|
||||
lastActivity: Date
|
||||
}
|
||||
|
||||
// TODO(usage-activity endpoint): there is no per-workspace activity API yet;
|
||||
// the ledger renders its empty state until events arrive from the backend.
|
||||
const events: ActivityEvent[] = []
|
||||
|
||||
export type ActivitySortField =
|
||||
| 'date'
|
||||
| 'user'
|
||||
| 'eventType'
|
||||
| 'detail'
|
||||
| 'credits'
|
||||
|
||||
export function useWorkspaceActivity(
|
||||
search: MaybeRefOrGetter<string>,
|
||||
pageSize: MaybeRefOrGetter<number>,
|
||||
selfName: MaybeRefOrGetter<string | null> = null
|
||||
) {
|
||||
const page = ref(1)
|
||||
const perPage = computed(() => Math.max(1, toValue(pageSize)))
|
||||
const sortField = ref<ActivitySortField>('date')
|
||||
const sortDirection = ref<'asc' | 'desc'>('desc')
|
||||
|
||||
// Members only see their own usage; credit inflows stay workspace-level.
|
||||
const base = computed<ActivityEvent[]>(() => {
|
||||
const self = toValue(selfName)
|
||||
if (!self) return events
|
||||
return events.filter((event) => event.credited || event.userName === self)
|
||||
})
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = toValue(search).trim().toLowerCase()
|
||||
if (!q) return base.value
|
||||
return base.value.filter(
|
||||
(event) =>
|
||||
event.userName.toLowerCase().includes(q) ||
|
||||
event.eventType.toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
|
||||
const sorted = computed(() => {
|
||||
const dir = sortDirection.value === 'asc' ? 1 : -1
|
||||
return [...filtered.value].sort((a, b) => {
|
||||
if (sortField.value === 'credits') return dir * (a.credits - b.credits)
|
||||
if (sortField.value === 'user')
|
||||
return dir * a.userName.localeCompare(b.userName)
|
||||
if (sortField.value === 'eventType')
|
||||
return dir * a.eventType.localeCompare(b.eventType)
|
||||
if (sortField.value === 'detail')
|
||||
return dir * a.detail.localeCompare(b.detail)
|
||||
return dir * (a.date.getTime() - b.date.getTime())
|
||||
})
|
||||
})
|
||||
|
||||
const total = computed(() => filtered.value.length)
|
||||
|
||||
const pagedItems = computed(() => {
|
||||
const start = (page.value - 1) * perPage.value
|
||||
return sorted.value.slice(start, start + perPage.value)
|
||||
})
|
||||
|
||||
function toggleSort(field: ActivitySortField) {
|
||||
if (sortField.value === field) {
|
||||
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
|
||||
} else {
|
||||
sortField.value = field
|
||||
sortDirection.value = 'desc'
|
||||
}
|
||||
}
|
||||
|
||||
watch([total, perPage], ([count]) => {
|
||||
const lastPage = Math.max(1, Math.ceil(count / perPage.value))
|
||||
if (page.value > lastPage) page.value = lastPage
|
||||
})
|
||||
|
||||
// Per-user rollups behind the User hover card: lifetime credits and the most
|
||||
// recent event, aggregated across the whole (unpaged) list.
|
||||
const userSummaries = computed(() => {
|
||||
const map = new Map<string, UserSummary>()
|
||||
for (const event of base.value) {
|
||||
if (event.credited) continue
|
||||
const existing = map.get(event.userName)
|
||||
if (!existing) {
|
||||
map.set(event.userName, {
|
||||
totalCredits: event.credits,
|
||||
lastActivity: event.date
|
||||
})
|
||||
} else {
|
||||
existing.totalCredits += event.credits
|
||||
if (event.date > existing.lastActivity)
|
||||
existing.lastActivity = event.date
|
||||
}
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
return {
|
||||
page,
|
||||
total,
|
||||
itemsPerPage: perPage,
|
||||
pagedItems,
|
||||
sortField,
|
||||
sortDirection,
|
||||
toggleSort,
|
||||
userSummaries
|
||||
}
|
||||
}
|
||||
@@ -583,6 +583,18 @@ export const useDialogService = () => {
|
||||
})
|
||||
}
|
||||
|
||||
async function showAutoReloadDialog() {
|
||||
const { default: component } =
|
||||
await import('@/platform/workspace/components/dialogs/AutoReloadDialogContent.vue')
|
||||
return dialogStore.showDialog({
|
||||
key: 'auto-reload',
|
||||
component,
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogProps
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function showMemberLimitDialog() {
|
||||
const { default: component } =
|
||||
await import('@/platform/workspace/components/dialogs/RequestMoreDialogContent.vue')
|
||||
@@ -773,6 +785,7 @@ export const useDialogService = () => {
|
||||
showRevokeInviteDialog,
|
||||
showInviteMemberDialog,
|
||||
showInviteMemberUpsellDialog,
|
||||
showAutoReloadDialog,
|
||||
showMemberLimitDialog,
|
||||
showWorkflowQueuedDialog,
|
||||
showBillingComingSoonDialog,
|
||||
|
||||
Reference in New Issue
Block a user