feat: add admin components for input, metrics, tables, and user forms
- Introduced AdminInput component for standardized input fields. - Created AdminMetricCard for displaying metrics with customizable tones. - Added AdminPlaceholderTable for loading states in tables. - Developed AdminSectionCard for consistent section layouts. - Implemented AdminSectionShell for organizing admin sections. - Added AdminSelect for dropdown selections with v-model support. - Created AdminTable for displaying tabular data with loading and empty states. - Introduced AdminTextarea for multi-line text input. - Developed AdminUserFormFields for user creation and editing forms. - Added useAdminPageHeader composable for managing admin page header state.
This commit is contained in:
@@ -7,11 +7,11 @@ import { useAppToast } from '@/composables/useAppToast';
|
||||
import { useUsageQuery } from '@/composables/useUsageQuery';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
|
||||
import BillingHistorySection from '@/routes/settings/components/billing/BillingHistorySection.vue';
|
||||
import BillingPlansSection from '@/routes/settings/components/billing/BillingPlansSection.vue';
|
||||
import BillingTopupDialog from '@/routes/settings/components/billing/BillingTopupDialog.vue';
|
||||
import BillingUsageSection from '@/routes/settings/components/billing/BillingUsageSection.vue';
|
||||
import BillingWalletRow from '@/routes/settings/components/billing/BillingWalletRow.vue';
|
||||
import BillingHistorySection from '@/routes/settings/Billing/components/BillingHistorySection.vue';
|
||||
import BillingPlansSection from '@/routes/settings/Billing/components/BillingPlansSection.vue';
|
||||
import BillingTopupDialog from '@/routes/settings/Billing/components/BillingTopupDialog.vue';
|
||||
import BillingUsageSection from '@/routes/settings/Billing/components/BillingUsageSection.vue';
|
||||
import BillingWalletRow from '@/routes/settings/Billing/components/BillingWalletRow.vue';
|
||||
import type { Plan as ModelPlan, PaymentHistoryItem as PaymentHistoryApiItem } from '@/server/gen/proto/app/v1/common';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useQuery } from '@pinia/colada';
|
||||
|
||||
114
src/routes/settings/Billing/components/BillingHistorySection.vue
Normal file
114
src/routes/settings/Billing/components/BillingHistorySection.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
import DownloadIcon from '@/components/icons/DownloadIcon.vue';
|
||||
|
||||
type PaymentHistoryItem = {
|
||||
id: string;
|
||||
date: string;
|
||||
amount: number;
|
||||
plan: string;
|
||||
status: string;
|
||||
invoiceId: string;
|
||||
currency: string;
|
||||
kind: string;
|
||||
details?: string[];
|
||||
};
|
||||
|
||||
defineProps<{
|
||||
title: string;
|
||||
description: string;
|
||||
items: PaymentHistoryItem[];
|
||||
loading?: boolean;
|
||||
downloadingId?: string | null;
|
||||
formatMoney: (amount: number) => string;
|
||||
getStatusStyles: (status: string) => string;
|
||||
getStatusLabel: (status: string) => string;
|
||||
dateLabel: string;
|
||||
amountLabel: string;
|
||||
planLabel: string;
|
||||
statusLabel: string;
|
||||
invoiceLabel: string;
|
||||
emptyLabel: string;
|
||||
downloadLabel: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'download', item: PaymentHistoryItem): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-6 py-4">
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="w-10 h-10 rounded-md bg-info/10 flex items-center justify-center shrink-0">
|
||||
<DownloadIcon class="w-5 h-5 text-info" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">{{ title }}</p>
|
||||
<p class="text-xs text-foreground/60 mt-0.5">{{ description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border border-border rounded-lg overflow-hidden">
|
||||
<div class="grid grid-cols-12 gap-4 px-4 py-3 text-xs font-medium text-foreground/60 uppercase tracking-wider bg-muted/30">
|
||||
<div class="col-span-3">{{ dateLabel }}</div>
|
||||
<div class="col-span-2">{{ amountLabel }}</div>
|
||||
<div class="col-span-3">{{ planLabel }}</div>
|
||||
<div class="col-span-2">{{ statusLabel }}</div>
|
||||
<div class="col-span-2 text-right">{{ invoiceLabel }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="px-4 py-6 space-y-3">
|
||||
<div v-for="index in 3" :key="index" class="grid grid-cols-12 gap-4 items-center animate-pulse">
|
||||
<div class="col-span-3 h-4 rounded bg-muted/50" />
|
||||
<div class="col-span-2 h-4 rounded bg-muted/50" />
|
||||
<div class="col-span-3 h-4 rounded bg-muted/50" />
|
||||
<div class="col-span-2 h-6 rounded bg-muted/50" />
|
||||
<div class="col-span-2 h-8 rounded bg-muted/50" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="items.length === 0" class="text-center py-12 text-foreground/60">
|
||||
<div class="w-16 h-16 rounded-full bg-muted/50 flex items-center justify-center mx-auto mb-4">
|
||||
<DownloadIcon class="w-8 h-8 text-foreground/40" />
|
||||
</div>
|
||||
<p>{{ emptyLabel }}</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="grid grid-cols-12 gap-4 px-4 py-3 items-center hover:bg-muted/30 transition-all border-t border-border"
|
||||
>
|
||||
<div class="col-span-3">
|
||||
<p class="text-sm font-medium text-foreground">{{ item.date }}</p>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<p class="text-sm text-foreground">{{ formatMoney(item.amount) }}</p>
|
||||
</div>
|
||||
<div class="col-span-3">
|
||||
<p class="text-sm text-foreground">{{ item.plan }}</p>
|
||||
<p v-if="item.details?.length" class="mt-1 text-xs text-foreground/60">
|
||||
{{ item.details.join(' · ') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<span :class="`inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium ${getStatusStyles(item.status)}`">
|
||||
{{ getStatusLabel(item.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-span-2 flex justify-end">
|
||||
<button
|
||||
class="flex items-center gap-2 px-3 py-1.5 text-sm text-foreground/70 hover:text-foreground hover:bg-muted/50 rounded-md transition-all disabled:opacity-60 disabled:cursor-wait"
|
||||
:disabled="downloadingId === item.id"
|
||||
@click="emit('download', item)"
|
||||
>
|
||||
<DownloadIcon class="w-4 h-4" />
|
||||
<span>{{ downloadingId === item.id ? '...' : downloadLabel }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import CreditCardIcon from '@/components/icons/CreditCardIcon.vue';
|
||||
import type { Plan as ModelPlan } from '@/server/gen/proto/app/v1/common';
|
||||
|
||||
defineProps<{
|
||||
title: string;
|
||||
description: string;
|
||||
isLoading: boolean;
|
||||
plans: ModelPlan[];
|
||||
currentPlanId?: string;
|
||||
selectingPlanId?: string | null;
|
||||
formatMoney: (amount: number) => string;
|
||||
getPlanStorageText: (plan: ModelPlan) => string;
|
||||
getPlanDurationText: (plan: ModelPlan) => string;
|
||||
getPlanUploadsText: (plan: ModelPlan) => string;
|
||||
currentPlanLabel: string;
|
||||
selectingLabel: string;
|
||||
chooseLabel: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', plan: ModelPlan): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-6 py-4">
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="w-10 h-10 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<CreditCardIcon class="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">{{ title }}</p>
|
||||
<p class="text-xs text-foreground/60 mt-0.5">{{ description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div v-for="i in 3" :key="i">
|
||||
<div class="h-[200px] rounded-lg bg-muted/50 animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div
|
||||
v-for="plan in plans.sort((a,b) => (a.price || 0) - (b.price || 0))"
|
||||
:key="plan.id"
|
||||
:class="[
|
||||
'border rounded-lg p-4 hover:bg-muted/30 transition-all flex flex-col',
|
||||
plan.id === currentPlanId ? 'border-primary/40 bg-primary/5' : 'border-border',
|
||||
]"
|
||||
>
|
||||
<div class="mb-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h3 class="text-lg font-semibold text-foreground">{{ plan.name }}</h3>
|
||||
<span
|
||||
v-if="plan.id === currentPlanId"
|
||||
class="inline-flex items-center rounded-full bg-primary/10 px-2 py-1 text-[11px] font-medium text-primary"
|
||||
>
|
||||
{{ currentPlanLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-foreground/60 mt-1 min-h-[2.5rem]">{{ plan.description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<span class="text-2xl font-bold text-foreground">{{ formatMoney(plan.price || 0) }}</span>
|
||||
<span class="text-foreground/60 text-sm"> / {{ $t('settings.billing.cycle.'+plan.cycle) }}</span>
|
||||
</div>
|
||||
<ul class="space-y-2 mb-4 text-sm">
|
||||
<li
|
||||
v-for="feature in plan.features || []"
|
||||
:key="feature"
|
||||
class="flex items-center gap-2 text-foreground/70"
|
||||
>
|
||||
<CheckIcon class="w-4 h-4 text-success shrink-0" />
|
||||
{{ feature }}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<button
|
||||
v-if="plan.id !== currentPlanId"
|
||||
:disabled="selectingPlanId === plan.id"
|
||||
:class="[
|
||||
'w-full py-2 px-4 rounded-md text-sm font-medium transition-all mt-a',
|
||||
selectingPlanId === plan.id
|
||||
? 'bg-muted/50 text-foreground/60 cursor-wait'
|
||||
: 'bg-primary text-white hover:bg-primary/90'
|
||||
]"
|
||||
@click="emit('select', plan)"
|
||||
>
|
||||
{{ selectingPlanId === plan.id ? selectingLabel : chooseLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
104
src/routes/settings/Billing/components/BillingTopupDialog.vue
Normal file
104
src/routes/settings/Billing/components/BillingTopupDialog.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AppDialog from '@/components/ui/AppDialog.vue';
|
||||
import AppInput from '@/components/ui/AppInput.vue';
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
|
||||
defineProps<{
|
||||
visible: boolean;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
presets: number[];
|
||||
amount: number | null;
|
||||
loading: boolean;
|
||||
customAmountLabel: string;
|
||||
amountPlaceholder: string;
|
||||
hint: string;
|
||||
cancelLabel: string;
|
||||
proceedLabel: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', value: boolean): void;
|
||||
(e: 'update:amount', value: number | null): void;
|
||||
(e: 'selectPreset', amount: number): void;
|
||||
(e: 'submit'): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog
|
||||
:visible="visible"
|
||||
@update:visible="emit('update:visible', $event)"
|
||||
:title="title"
|
||||
maxWidthClass="max-w-md"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-foreground/70">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-4 gap-3">
|
||||
<button
|
||||
v-for="preset in presets"
|
||||
:key="preset"
|
||||
:class="[
|
||||
'py-2 px-3 rounded-md bg-header text-sm font-medium transition-all hover:bg-gray-500',
|
||||
amount === preset
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-muted/50 text-foreground hover:bg-muted'
|
||||
]"
|
||||
@click="emit('selectPreset', preset)"
|
||||
>
|
||||
${{ preset }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-foreground">{{ customAmountLabel }}</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-lg font-semibold text-foreground">$</span>
|
||||
<AppInput
|
||||
:model-value="amount"
|
||||
type="number"
|
||||
:placeholder="amountPlaceholder"
|
||||
inputClass="flex-1"
|
||||
min="1"
|
||||
step="1"
|
||||
@update:model-value="emit('update:amount', typeof $event === 'number' || $event === null
|
||||
? $event
|
||||
: ($event === '' ? null : Number($event)))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-muted/30 rounded-md p-3 text-xs text-foreground/60">
|
||||
<p>{{ hint }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:disabled="loading"
|
||||
@click="emit('update:visible', false)"
|
||||
>
|
||||
{{ cancelLabel }}
|
||||
</AppButton>
|
||||
<AppButton
|
||||
size="sm"
|
||||
:loading="loading"
|
||||
:disabled="!amount || amount < 1 || loading"
|
||||
@click="emit('submit')"
|
||||
>
|
||||
<template #icon>
|
||||
<CheckIcon class="w-4 h-4" />
|
||||
</template>
|
||||
{{ proceedLabel }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import ActivityIcon from '@/components/icons/ActivityIcon.vue';
|
||||
import UploadIcon from '@/components/icons/UploadIcon.vue';
|
||||
|
||||
defineProps<{
|
||||
storageTitle: string;
|
||||
storageDescription: string;
|
||||
storagePercentage: number;
|
||||
uploadsTitle: string;
|
||||
uploadsDescription: string;
|
||||
uploadsPercentage: number;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="px-6 py-4 hover:bg-muted/30 transition-all">
|
||||
<div class="flex items-center gap-4 mb-3">
|
||||
<div class="w-10 h-10 rounded-md bg-accent/10 flex items-center justify-center shrink-0">
|
||||
<ActivityIcon class="w-5 h-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">{{ storageTitle }}</p>
|
||||
<p class="text-xs text-foreground/60 mt-0.5">{{ storageDescription }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full bg-muted/50 rounded-full overflow-hidden" style="height: 6px">
|
||||
<div
|
||||
class="bg-primary h-full rounded-full transition-all duration-300"
|
||||
:style="{ width: `${storagePercentage}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 hover:bg-muted/30 transition-all border-t border-border">
|
||||
<div class="flex items-center gap-4 mb-3">
|
||||
<div class="w-10 h-10 rounded-md bg-info/10 flex items-center justify-center shrink-0">
|
||||
<UploadIcon class="w-5 h-5 text-info" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">{{ uploadsTitle }}</p>
|
||||
<p class="text-xs text-foreground/60 mt-0.5">{{ uploadsDescription }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full bg-muted/50 rounded-full overflow-hidden" style="height: 6px">
|
||||
<div
|
||||
class="bg-info h-full rounded-full transition-all duration-300"
|
||||
:style="{ width: `${uploadsPercentage}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
49
src/routes/settings/Billing/components/BillingWalletRow.vue
Normal file
49
src/routes/settings/Billing/components/BillingWalletRow.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import CoinsIcon from '@/components/icons/CoinsIcon.vue';
|
||||
import PlusIcon from '@/components/icons/PlusIcon.vue';
|
||||
import SettingsRow from '@/routes/settings/components/SettingsRow.vue';
|
||||
|
||||
defineProps<{
|
||||
title: string;
|
||||
description: string;
|
||||
buttonLabel: string;
|
||||
subscriptionTitle?: string;
|
||||
subscriptionDescription?: string;
|
||||
subscriptionTone?: 'default' | 'warning';
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'topup'): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsRow :title="title" :description="description" iconBoxClass="bg-primary/10">
|
||||
<template #icon>
|
||||
<CoinsIcon class="w-5 h-5 text-primary" />
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
<!-- <div
|
||||
v-if="subscriptionTitle || subscriptionDescription"
|
||||
class="rounded-md border px-3 py-2 text-right"
|
||||
:class="subscriptionTone === 'warning'
|
||||
? 'border-warning/30 bg-warning/10 text-warning'
|
||||
: 'border-border bg-muted/30 text-foreground/70'"
|
||||
>
|
||||
<p v-if="subscriptionTitle" class="text-xs font-medium">{{ subscriptionTitle }}</p>
|
||||
<p v-if="subscriptionDescription" class="mt-0.5 text-xs">{{ subscriptionDescription }}</p>
|
||||
</div> -->
|
||||
|
||||
<AppButton size="sm" @click="emit('topup')">
|
||||
<template #icon>
|
||||
<PlusIcon class="w-4 h-4" />
|
||||
</template>
|
||||
{{ buttonLabel }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
</template>
|
||||
Reference in New Issue
Block a user