feat(aftersale): per-line refund/return flow with ledger-backed completion (add-aftersale-refunds)

This commit is contained in:
Chengdong Zhang
2026-09-23 16:56:30 +08:00
parent 93e5a05d48
commit 2c2b54c21c
43 changed files with 4889 additions and 32 deletions
+110
View File
@@ -0,0 +1,110 @@
<script setup lang="ts">
import type { Aftersale, AftersaleKind, Order } from "@vmall/shared";
definePageMeta({ middleware: "auth" });
const { t } = useI18n();
const { $api } = useNuxtApp();
const route = useRoute();
const { ensureCurrencies, exponentFor } = usePrice();
const order = ref<Order | null>(null);
const aftersales = ref<Aftersale[]>([]);
const loading = ref(true);
const working = ref(false);
const errorKey = ref("");
const kind = ref<AftersaleKind>("refund_only");
const reason = ref("");
const amountMajor = ref("");
const evidence = ref("");
const orderId = computed(() => String(route.query.order_id ?? ""));
const itemId = computed(() => String(route.query.item_id ?? ""));
const item = computed(() => order.value?.items.find((entry) => entry.id === itemId.value) ?? null);
const remainingMinor = computed(() => {
const line = item.value;
if (!line || !order.value) return 0;
const refunded = aftersales.value
.filter((row) => row.order_item_id === line.id && row.status === "refunded")
.reduce((sum, row) => sum + row.amount_minor, 0);
return Math.max(0, line.unit_price_minor * line.qty - refunded);
});
async function load(): Promise<void> {
loading.value = true;
try {
await ensureCurrencies();
const [loadedOrder, loadedAftersales] = await Promise.all([$api.getOrder(orderId.value), $api.listMyAftersales()]);
order.value = loadedOrder;
aftersales.value = loadedAftersales;
} catch {
errorKey.value = "user.aftersaleLoadFailed";
} finally {
loading.value = false;
}
}
function amountMinor(): number | null {
const raw = Number(amountMajor.value);
if (!Number.isFinite(raw) || raw <= 0 || !order.value) return null;
const exponent = exponentFor(order.value.currency);
const minor = Math.round(raw * 10 ** exponent);
return Number.isSafeInteger(minor) && minor > 0 ? minor : null;
}
async function submit(): Promise<void> {
errorKey.value = "";
const content = reason.value.trim();
const amount = amountMinor();
if (!item.value || !order.value || !content || amount === null) {
errorKey.value = "user.validationRequired";
return;
}
if (amount > remainingMinor.value) {
errorKey.value = "user.aftersaleAmountHint";
return;
}
working.value = true;
try {
const created = await $api.applyForAftersale({
order_item_id: item.value.id,
kind: kind.value,
reason: { en: content, zh: content },
amount_minor: amount,
evidence: evidence.value.split("\n").map((url) => url.trim()).filter(Boolean),
});
await navigateTo(`/user/aftersales/${created.id}`);
} catch {
errorKey.value = "user.aftersaleSubmitFailed";
} finally {
working.value = false;
}
}
onMounted(() => void load());
</script>
<template>
<VCard class="min-h-[560px]">
<h1 class="mb-4 text-xl font-bold text-text">{{ t("user.aftersaleApply") }}</h1>
<div v-if="loading" class="py-5 text-muted">{{ t("common.loading") }}</div>
<form v-else class="grid max-w-[640px] gap-4" @submit.prevent="submit">
<VCard :padded="false" class="flex items-center gap-3 border border-border p-4">
<img class="h-14 w-14 border border-border object-contain" :src="item.image || '/mock/product-1.svg'" :alt="item.product_name.en" />
<div class="min-w-0 flex-1"><p class="m-0 truncate text-sm text-text">{{ item.product_name.en }}</p><p class="mt-1 text-xs text-muted">{{ item.sku_code }} × {{ item.qty }}</p></div>
</VCard>
<fieldset class="grid gap-2 border-0 p-0">
<legend class="mb-1 block text-[13px] text-muted">{{ t("user.aftersaleType") }}</legend>
<label class="text-sm text-text"><input v-model="kind" class="mr-1 accent-primary" type="radio" value="refund_only" /> {{ t("user.refundOnly") }}</label>
<label class="text-sm text-text"><input v-model="kind" class="mr-1 accent-primary" type="radio" value="return_refund" /> {{ t("user.returnRefund") }}</label>
</fieldset>
<VField :label="t('user.aftersaleReason')"><textarea v-model="reason" class="min-h-24 rounded-md border border-border bg-surface p-2.5 text-sm text-text" :placeholder="t('user.aftersaleReasonHint')" /></VField>
<VField :label="t('user.aftersaleAmount')">
<VInput v-model="amountMajor" />
<p class="m-0 mt-1 text-xs text-muted">{{ t("user.aftersaleAmountHint") }} {{ t("user.aftersaleRemaining") }}: <PriceText :amount-minor="remainingMinor" :currency="order.currency" /></p>
</VField>
<VField :label="t('user.aftersaleEvidence')"><textarea v-model="evidence" class="min-h-20 rounded-md border border-border bg-surface p-2.5 text-sm text-text" :placeholder="t('user.aftersaleEvidenceHint')" /></VField>
<p v-if="errorKey" class="m-0 text-xs text-danger">{{ t(errorKey) }}</p>
<VBtn class="w-fit" variant="primary" type="submit" :disabled="working">{{ t("user.submitAftersale") }}</VBtn>
</form>
</VCard>
</template>