develop-updateui #1

Merged
lethdat merged 78 commits from develop-updateui into master 2026-04-02 05:59:23 +00:00
3 changed files with 175 additions and 140 deletions
Showing only changes of commit 1f8fdad2da - Show all commits

View File

@@ -1,31 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import { client as rpcClient } from '@/api/rpcclient';
import AppButton from '@/components/ui/AppButton.vue'; import AppButton from '@/components/ui/AppButton.vue';
import { useAppToast } from '@/composables/useAppToast';
import { useUsageQuery } from '@/composables/useUsageQuery'; import { useUsageQuery } from '@/composables/useUsageQuery';
import { getApiErrorMessage } from '@/lib/utils';
import BillingTopupDialog from '@/routes/settings/Billing/components/BillingTopupDialog.vue'; import BillingTopupDialog from '@/routes/settings/Billing/components/BillingTopupDialog.vue';
import BillingUsageSection from '@/routes/settings/Billing/components/BillingUsageSection.vue'; import BillingUsageSection from '@/routes/settings/Billing/components/BillingUsageSection.vue';
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue'; import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
import type { Plan as ModelPlan } from '@/server/gen/proto/app/v1/common'; import type { Plan as ModelPlan } from '@/server/gen/proto/app/v1/common';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { useTranslation } from 'i18next-vue';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import SettingsRow from '../components/SettingsRow.vue'; import SettingsRow from '../components/SettingsRow.vue';
import PaymentHistory from './components/PaymentHistory'; import PaymentHistory from './components/PaymentHistory';
import PlanSelection from './components/PlanSelection'; import PlanSelection from './components/PlanSelection';
import UpgradePlan from './components/UpgradePlan'; import UpgradePlan from './components/UpgradePlan';
const toast = useAppToast();
const auth = useAuthStore(); const auth = useAuthStore();
const { t } = useTranslation();
const { refetch: refetchUsage } = useUsageQuery(); const { refetch: refetchUsage } = useUsageQuery();
const topupDialogVisible = ref(false); const topupDialogVisible = ref(false);
const topupAmount = ref<number | null>(null); const topupAmount = ref<number | null>(null);
const topupLoading = ref(false);
const topupPresets = [10, 20, 50, 100];
const upgradeDialogVisible = ref(false); const upgradeDialogVisible = ref(false);
const selectedPlan = ref<ModelPlan | null>(null); const selectedPlan = ref<ModelPlan | null>(null);
@@ -63,41 +55,11 @@ const handleUpgradeSuccess = async () => {
await refreshBillingState(); await refreshBillingState();
}; };
const handleTopup = async (amount: number) => {
topupLoading.value = true;
try {
await rpcClient.topupWallet({ amount });
await refreshBillingState();
toast.add({
severity: 'success',
summary: t('settings.billing.toast.topupSuccessSummary'),
detail: t('settings.billing.toast.topupSuccessDetail', { amount: auth.formatMoney(amount) }),
life: 3000,
});
topupDialogVisible.value = false;
topupAmount.value = null;
} catch (error) {
console.error(error);
toast.add({
severity: 'error',
summary: t('settings.billing.toast.topupFailedSummary'),
detail: getApiErrorMessage(error, t('settings.billing.toast.topupFailedDetail')),
life: 5000,
});
} finally {
topupLoading.value = false;
}
};
const openTopupDialog = () => { const openTopupDialog = () => {
topupAmount.value = null; topupAmount.value = null;
topupDialogVisible.value = true; topupDialogVisible.value = true;
}; };
const selectPreset = (amount: number) => {
topupAmount.value = amount;
};
</script> </script>
<template> <template>
@@ -131,16 +93,7 @@ const selectPreset = (amount: number) => {
<PaymentHistory /> <PaymentHistory />
</SettingsSectionCard> </SettingsSectionCard>
<BillingTopupDialog <BillingTopupDialog v-model="topupDialogVisible" />
:visible="topupDialogVisible"
:presets="topupPresets"
:amount="topupAmount"
:loading="topupLoading"
@update:visible="topupDialogVisible = $event"
@update:amount="topupAmount = $event"
@selectPreset="selectPreset"
@submit="handleTopup(topupAmount || 0)"
/>
<UpgradePlan <UpgradePlan
:visible="upgradeDialogVisible" :visible="upgradeDialogVisible"

View File

@@ -1,28 +1,67 @@
<script setup lang="ts"> <script setup lang="ts">
import { client } from '@/api/rpcclient';
import CheckIcon from '@/components/icons/CheckIcon.vue'; import CheckIcon from '@/components/icons/CheckIcon.vue';
import AppButton from '@/components/ui/AppButton.vue'; import AppButton from '@/components/ui/AppButton.vue';
import AppDialog from '@/components/ui/AppDialog.vue'; import AppDialog from '@/components/ui/AppDialog.vue';
import AppInput from '@/components/ui/AppInput.vue'; import AppInput from '@/components/ui/AppInput.vue';
import { useAppToast } from '@/composables/useAppToast';
import { useUsageQuery } from '@/composables/useUsageQuery';
import { getApiErrorMessage } from '@/lib/utils';
import { useAuthStore } from '@/stores/auth';
import { useTranslation } from 'i18next-vue';
defineProps<{ const visible = defineModel<boolean>();
visible: boolean; const toast = useAppToast();
presets: number[]; const auth = useAuthStore();
amount: number | null; const { t } = useTranslation();
loading: boolean; const { refetch: refetchUsage } = useUsageQuery();
}>();
const topupDialogVisible = ref(false);
const topupAmount = ref<number | null>(null);
const topupLoading = ref(false);
const topupPresets = [10, 20, 50, 100];
const refreshBillingState = async () => {
await Promise.allSettled([
auth.fetchMe(),
refetchUsage(),
]);
};
const handleTopup = async (amount: number) => {
topupLoading.value = true;
try {
await client.topupWallet({ amount });
await refreshBillingState();
toast.add({
severity: 'success',
summary: t('settings.billing.toast.topupSuccessSummary'),
detail: t('settings.billing.toast.topupSuccessDetail', { amount: auth.formatMoney(amount) }),
life: 3000,
});
topupDialogVisible.value = false;
topupAmount.value = null;
} catch (error) {
console.error(error);
toast.add({
severity: 'error',
summary: t('settings.billing.toast.topupFailedSummary'),
detail: getApiErrorMessage(error, t('settings.billing.toast.topupFailedDetail')),
life: 5000,
});
} finally {
topupLoading.value = false;
visible.value = false;
}
};
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> </script>
<template> <template>
<AppDialog <AppDialog
:visible="visible" :visible="visible!"
@update:visible="emit('update:visible', $event)" @update:visible="visible = $event"
:title="$t('settings.billing.topupDialog.title')" :title="$t('settings.billing.topupDialog.title')"
maxWidthClass="max-w-md" maxWidthClass="max-w-md"
> >
@@ -33,15 +72,15 @@ const emit = defineEmits<{
<div class="grid grid-cols-4 gap-3"> <div class="grid grid-cols-4 gap-3">
<button <button
v-for="preset in presets" v-for="preset in topupPresets"
:key="preset" :key="preset"
:class="[ :class="[
'py-2 px-3 rounded-md bg-header text-sm font-medium transition-all hover:bg-gray-500', 'py-2 px-3 rounded-md bg-header text-sm font-medium transition-all hover:bg-gray-500',
amount === preset topupAmount === preset
? 'bg-primary text-white' ? 'bg-primary text-white'
: 'bg-muted/50 text-foreground hover:bg-muted' : 'bg-muted/50 text-foreground hover:bg-muted'
]" ]"
@click="emit('selectPreset', preset)" @click="topupAmount = preset"
> >
${{ preset }} ${{ preset }}
</button> </button>
@@ -52,15 +91,15 @@ const emit = defineEmits<{
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-lg font-semibold text-foreground">$</span> <span class="text-lg font-semibold text-foreground">$</span>
<AppInput <AppInput
:model-value="amount" :model-value="topupAmount"
type="number" type="number"
:placeholder="$t('settings.billing.topupDialog.enterAmount')" :placeholder="$t('settings.billing.topupDialog.enterAmount')"
inputClass="flex-1" inputClass="flex-1"
min="1" min="1"
step="1" step="1"
@update:model-value="emit('update:amount', typeof $event === 'number' || $event === null @update:model-value="topupAmount = typeof $event === 'number' || $event === null
? $event ? $event
: ($event === '' ? null : Number($event)))" : ($event === '' ? null : Number($event))"
/> />
</div> </div>
</div> </div>
@@ -75,16 +114,16 @@ const emit = defineEmits<{
<AppButton <AppButton
variant="secondary" variant="secondary"
size="sm" size="sm"
:disabled="loading" :disabled="topupLoading"
@click="emit('update:visible', false)" @click="visible = false; topupAmount = null"
> >
{{ $t('common.cancel') }} {{ $t('common.cancel') }}
</AppButton> </AppButton>
<AppButton <AppButton
size="sm" size="sm"
:loading="loading" :loading="topupLoading"
:disabled="!amount || amount < 1 || loading" :disabled="!topupAmount || topupAmount < 1 || topupLoading"
@click="emit('submit')" @click="handleTopup(topupAmount!)"
> >
<template #icon> <template #icon>
<CheckIcon class="w-4 h-4" /> <CheckIcon class="w-4 h-4" />

View File

@@ -1,12 +1,14 @@
import { client } from "@/api/rpcclient"; import { client } from "@/api/rpcclient";
import DownloadIcon from "@/components/icons/DownloadIcon.vue"; import DownloadIcon from "@/components/icons/DownloadIcon.vue";
import ListIcon from "@/components/icons/ListIcon.vue"; import ListIcon from "@/components/icons/ListIcon.vue";
import BaseTable from "@/components/ui/BaseTable.vue";
import { useAppToast } from "@/composables/useAppToast"; import { useAppToast } from "@/composables/useAppToast";
import { getApiErrorMessage, getStatusStyles } from "@/lib/utils"; import { getApiErrorMessage, getStatusStyles } from "@/lib/utils";
import { PaymentHistoryItem } from "@/server/gen/proto/app/v1/common"; import { PaymentHistoryItem } from "@/server/gen/proto/app/v1/common";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { useQuery } from "@pinia/colada"; import { useQuery } from "@pinia/colada";
import { useTranslation } from "i18next-vue"; import { useTranslation } from "i18next-vue";
import type { ColumnDef } from "@tanstack/vue-table";
const normalizeHistoryStatus = (status?: string) => { const normalizeHistoryStatus = (status?: string) => {
switch ((status || '').toLowerCase()) { switch ((status || '').toLowerCase()) {
@@ -28,7 +30,7 @@ const normalizeHistoryStatus = (status?: string) => {
const PaymentHistory = defineComponent({ const PaymentHistory = defineComponent({
name: 'PaymentHistory', name: 'PaymentHistory',
setup(props, ctx) { setup() {
const auth = useAuthStore(); const auth = useAuthStore();
const { t } = useTranslation(); const { t } = useTranslation();
const toast = useAppToast(); const toast = useAppToast();
@@ -120,11 +122,87 @@ const PaymentHistory = defineComponent({
downloadingInvoiceId.value = null; downloadingInvoiceId.value = null;
} }
}; };
const columns: ColumnDef<ReturnType<typeof mapHistoryItem>>[] = [
{
accessorKey: 'date',
header: t('settings.billing.table.date'),
cell: ({ getValue }) => (
<p class="text-sm font-medium text-foreground">{getValue<string>()}</p>
),
meta: {
headerClass: 'col-span-3',
cellClass: 'col-span-3',
},
},
{
accessorKey: 'amount',
header: t('settings.billing.table.amount'),
cell: ({ row }) => (
<p class="text-sm text-foreground">{auth.formatMoney(row.original.amount)}</p>
),
meta: {
headerClass: 'col-span-2',
cellClass: 'col-span-2',
},
},
{
accessorKey: 'plan',
header: t('settings.billing.table.plan'),
cell: ({ row }) => (
<>
<p class="text-sm text-foreground">{row.original.plan}</p>
{row.original.details?.length ? (
<p class="mt-1 text-xs text-foreground/60">
{row.original.details.join(' · ')}
</p>
) : null}
</>
),
meta: {
headerClass: 'col-span-3',
cellClass: 'col-span-3',
},
},
{
accessorKey: 'status',
header: t('settings.billing.table.status'),
cell: ({ row }) => (
<span class={`inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium ${getStatusStyles(row.original.status)}`}>
{t('settings.billing.status.' + row.original.status)}
</span>
),
meta: {
headerClass: 'col-span-2',
cellClass: 'col-span-2',
},
},
{
accessorKey: 'invoice',
enableSorting: false,
header: t('settings.billing.table.invoice'),
cell: ({ row }) => (
<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={downloadingInvoiceId.value === row.original.id}
onClick={() => handleDownloadInvoice(row.original as PaymentHistoryItem)}
>
<DownloadIcon class="w-4 h-4" />
<span>{downloadingInvoiceId.value === row.original.id ? '...' : t('settings.billing.download')}</span>
</button>
),
meta: {
headerClass: 'col-span-2 flex justify-center',
cellClass: 'col-span-2 justify-center',
},
},
];
return () => ( return () => (
<div class="px-6 py-4"> <div class="px-6 py-4">
<div class="flex items-center gap-4 mb-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"> <div class="w-10 h-10 rounded-md bg-accent/10 flex items-center justify-center shrink-0">
<ListIcon class="w-6 h-6 text-info" /> <ListIcon class="w-6 h-6 text-primary" />
</div> </div>
<div> <div>
<p class="text-sm font-medium text-foreground">{t('settings.billing.paymentHistory')}</p> <p class="text-sm font-medium text-foreground">{t('settings.billing.paymentHistory')}</p>
@@ -132,71 +210,36 @@ const PaymentHistory = defineComponent({
</div> </div>
</div> </div>
<div class="border border-border rounded-lg overflow-hidden"> <BaseTable
<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"> data={data.value || []}
<div class="col-span-3">{t('settings.billing.table.date')}</div> columns={columns}
<div class="col-span-2">{t('settings.billing.table.amount')}</div> loading={isLoading.value}
<div class="col-span-3">{t('settings.billing.table.plan')}</div> emptyText={t('settings.billing.noPaymentHistory')}
<div class="col-span-2">{t('settings.billing.table.status')}</div> >
<div class="col-span-2 text-right">{t('settings.billing.table.invoice')}</div> {{
</div> loading: () => (
{isLoading.value && (<div class="px-4 py-6 space-y-3"> <div class="px-4 py-6 space-y-3">
{Array.from({ length: 3 }).map((_, index) => ( {Array.from({ length: 3 }).map((_, index) => (
<div key={index} class="grid grid-cols-12 gap-4 items-center animate-pulse"> <div 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-3 h-4 rounded bg-muted/50" />
<div class="col-span-2 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-3 h-4 rounded bg-muted/50" />
<div class="col-span-2 h-6 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 class="col-span-2 h-8 rounded bg-muted/50" />
</div>
))}
</div> </div>
))} ),
</div>)} empty: () => (
{data.value?.length === 0 && !isLoading.value && (<div class="text-center py-12 text-foreground/60"> <div 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"> <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" /> <DownloadIcon class="w-8 h-8 text-foreground/40" />
</div> </div>
<p>{t('settings.billing.noPaymentHistory')}</p> <p>{t('settings.billing.noPaymentHistory')}</p>
</div>)}
{data.value?.map((item) => (
<div
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>
<div class="col-span-2"> )
<p class="text-sm text-foreground">{auth.formatMoney(item.amount)}</p> }}
</div> </BaseTable>
<div class="col-span-3">
<p class="text-sm text-foreground">{item.plan}</p>
{
item.details?.length ? (
<p class="mt-1 text-xs text-foreground/60">
{item.details.join(' · ')}
</p>
) : null
}
</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)}`}>
{t('settings.billing.status.' + 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={downloadingInvoiceId.value === item.id}
onClick={() => handleDownloadInvoice(item)}
>
<DownloadIcon class="w-4 h-4" />
<span>{downloadingInvoiceId.value === item.id ? '...' : t('settings.billing.download')}</span>
</button>
</div>
</div>
))}
</div>
</div> </div>
); );
} }