Files
Aether/frontend/src/features/overview/costs/ExpenseDialog.vue
T
elky 066ea87d72 feat: revamp analytics dashboards and harden database migrations
Add dashboard and overview analytics, health monitoring, provider expense tracking, and announcement updates across the gateway and frontend.

Keep schema migrations free of historical backfills while preserving automatic backfill execution. Bound migration deadlines, run schema preparation before Compose replacement, and anonymize deleted dashboard users.

Include the current documentation cleanup and regression coverage.
2026-10-01 11:48:17 +08:00

191 lines
7.6 KiB
Vue

<template>
<Dialog
:open="open"
:title="t('登记提供商支出', 'Record provider expense')"
:description="t('记录已支付的充值、套餐或其他费用', 'Record a paid recharge, subscription or other expense')"
size="lg"
:persistent="saving"
@update:open="value => !saving && emit('update:open', value)"
>
<form
id="provider-expense-form"
class="grid gap-4 py-2 sm:grid-cols-2"
@submit.prevent="submit"
>
<label class="grid gap-2 text-sm sm:col-span-2">
{{ t('提供商', 'Provider') }}
<select
v-model="draft.provider_id"
required
class="h-10 min-w-0 rounded-lg border bg-background px-3"
:disabled="saving"
>
<option
value=""
disabled
>{{ t('选择提供商', 'Select a provider') }}</option>
<option
v-for="provider in providers"
:key="provider.provider_id"
:value="provider.provider_id"
>{{ provider.provider_name }}</option>
</select>
</label>
<label class="grid gap-2 text-sm">
{{ t('支出类型', 'Expense type') }}
<select
v-model="draft.kind"
class="h-10 rounded-lg border bg-background px-3"
:disabled="saving"
>
<option value="recharge">{{ t('余额充值', 'Balance recharge') }}</option>
<option value="subscription">{{ t('套餐 / 续费', 'Subscription / renewal') }}</option>
<option value="other">{{ t('其他支出', 'Other expense') }}</option>
</select>
</label>
<label class="grid gap-2 text-sm">
{{ t('币种', 'Currency') }}
<input
v-model="draft.currency"
maxlength="3"
pattern="[A-Za-z]{3}"
required
class="h-10 min-w-0 rounded-lg border bg-background px-3 uppercase"
:disabled="saving"
>
</label>
<label class="grid gap-2 text-sm">
{{ t('实付金额', 'Amount paid') }}
<input
v-model="draft.amount"
inputmode="decimal"
placeholder="0.00"
required
class="h-10 min-w-0 rounded-lg border bg-background px-3"
:disabled="saving"
>
</label>
<label class="grid gap-2 text-sm">
{{ t('付款时间', 'Paid at') }} · {{ timezone }}
<input
v-model="draft.paid_at"
type="datetime-local"
required
class="h-10 min-w-0 rounded-lg border bg-background px-3"
:disabled="saving"
>
</label>
<template v-if="draft.kind === 'subscription'">
<label class="grid gap-2 text-sm">
{{ t('套餐开始(选填)', 'Plan starts (optional)') }}
<input
v-model="draft.period_start"
type="date"
class="h-10 min-w-0 rounded-lg border bg-background px-3"
:disabled="saving"
>
</label>
<label class="grid gap-2 text-sm">
{{ t('套餐到期(选填)', 'Plan expires (optional)') }}
<input
v-model="draft.period_end"
type="date"
class="h-10 min-w-0 rounded-lg border bg-background px-3"
:disabled="saving"
>
</label>
</template>
<label class="grid gap-2 text-sm sm:col-span-2">
{{ t('账单 / 交易号(选填)', 'Invoice / reference (optional)') }}
<input
v-model="draft.external_reference"
maxlength="200"
class="h-10 min-w-0 rounded-lg border bg-background px-3"
:disabled="saving"
>
</label>
<label class="grid gap-2 text-sm sm:col-span-2">
{{ t('备注(选填)', 'Note (optional)') }}
<textarea
v-model="draft.note"
maxlength="1000"
rows="2"
class="min-w-0 resize-y rounded-lg border bg-background p-3"
:disabled="saving"
/>
</label>
<p
v-if="error"
class="text-sm text-destructive sm:col-span-2"
role="alert"
>
{{ error }}
</p>
</form>
<template #footer>
<Button
type="submit"
form="provider-expense-form"
:disabled="saving || !providers.length"
>
{{ saving ? t('保存中…', 'Saving…') : t('保存记录', 'Save record') }}
</Button>
<Button
variant="outline"
:disabled="saving"
@click="emit('update:open', false)"
>
{{ t('取消', 'Cancel') }}
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { reactive, ref, watch } from 'vue'
import { Button, Dialog } from '@/components/ui'
import { providerFinanceApi, type ProviderAccount, type ProviderExpenseKind } from '@/api/providerFinance'
import { useOverviewI18n } from '../i18n'
import { zonedInput, zonedInstant } from '../query'
const props = defineProps<{ open: boolean; providers: ProviderAccount[]; timezone: string }>()
const emit = defineEmits<{ 'update:open': [value: boolean]; saved: [] }>()
const { t } = useOverviewI18n()
const saving = ref(false)
const error = ref('')
const draft = reactive({ provider_id: '', kind: 'recharge' as ProviderExpenseKind, amount: '', currency: 'USD', paid_at: '', period_start: '', period_end: '', note: '', external_reference: '' })
let pending: { body: string; id: string } | null = null
watch(() => props.open, open => {
if (open && !draft.paid_at) draft.paid_at = zonedInput(new Date(), props.timezone)
})
async function submit() {
if (saving.value) return
error.value = ''
const paidAt = zonedInstant(draft.paid_at, props.timezone)
const start = draft.kind === 'subscription' && draft.period_start ? zonedInstant(`${draft.period_start}T00:00`, props.timezone) : null
const end = draft.kind === 'subscription' && draft.period_end ? zonedInstant(`${draft.period_end}T00:00`, props.timezone) : null
if (!draft.provider_id || !paidAt || !/^\d{1,12}(\.\d{1,8})?$/.test(draft.amount.trim()) || Number(draft.amount) <= 0 || !/^[A-Za-z]{3}$/.test(draft.currency)) {
error.value = t('请填写提供商、有效付款时间、正数金额及三位币种代码。', 'Enter a provider, valid payment time, positive amount and three-letter currency code.')
return
}
if (draft.kind === 'subscription' && ((Boolean(start) !== Boolean(end)) || (draft.period_start && !start) || (draft.period_end && !end) || (start && end && start >= end))) {
error.value = t('请同时填写套餐开始和到期时间,且到期时间晚于开始时间。', 'Enter both plan dates, with expiry after the start.')
return
}
const input = { provider_id: draft.provider_id, kind: draft.kind, amount: draft.amount.trim(), currency: draft.currency.toUpperCase(), paid_at: paidAt, period_start: start, period_end: end, note: draft.note.trim() || null, external_reference: draft.external_reference.trim() || null }
const body = JSON.stringify(input)
if (!pending || pending.body !== body) pending = { body, id: crypto.randomUUID() }
saving.value = true
try {
await providerFinanceApi.record({ ...input, client_request_id: pending.id })
pending = null
Object.assign(draft, { provider_id: '', kind: 'recharge', amount: '', currency: 'USD', paid_at: '', period_start: '', period_end: '', note: '', external_reference: '' })
emit('saved')
emit('update:open', false)
} catch (cause) {
const failure = cause as { response?: { data?: { detail?: string; message?: string } }; message?: string }
error.value = failure.response?.data?.detail || failure.response?.data?.message || failure.message || t('保存失败,请重试', 'Save failed; try again')
} finally { saving.value = false }
}
</script>