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:
2026-03-24 07:08:44 +00:00
parent e854c68ad0
commit b60f65e4d1
100 changed files with 9270 additions and 2204 deletions

View File

@@ -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';

View 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>

View File

@@ -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>

View 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>

View File

@@ -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>

View 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>