wip(freight): migration 0017 + model/column-list groundwork for add-freight-templates
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ApiError, t as localized } from "@vmall/shared";
|
||||
import type { AftersaleDetail, AftersaleMessage, AftersaleStatus, LocalizedText } from "@vmall/shared";
|
||||
import type {
|
||||
AftersaleDetail,
|
||||
AftersaleMessage,
|
||||
AftersaleStatus,
|
||||
LocalizedText,
|
||||
} from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
@@ -106,7 +111,8 @@ async function run(action: ShopAction): Promise<void> {
|
||||
try {
|
||||
if (action === "approve") await $api.shop.approveAftersale(aftersaleId.value);
|
||||
else if (action === "reject") await $api.shop.rejectAftersale(aftersaleId.value);
|
||||
else if (action === "confirmReceipt") await $api.shop.confirmAftersaleReceipt(aftersaleId.value);
|
||||
else if (action === "confirmReceipt")
|
||||
await $api.shop.confirmAftersaleReceipt(aftersaleId.value);
|
||||
else await $api.shop.refundAftersale(aftersaleId.value);
|
||||
notice.value = translate(doneKey[action]);
|
||||
} catch (err: unknown) {
|
||||
@@ -154,10 +160,14 @@ onMounted(() => {
|
||||
<template>
|
||||
<VPage :title="$t('aftersale.detail')">
|
||||
<template #actions>
|
||||
<NuxtLink class="inline-flex items-center justify-center rounded-md border border-border bg-surface px-4 py-2 text-sm font-medium text-text hover:bg-bg" to="/aftersales">{{ $t("common.back") }}</NuxtLink>
|
||||
<NuxtLink
|
||||
class="border-border bg-surface text-text hover:bg-bg inline-flex items-center justify-center rounded-md border px-4 py-2 text-sm font-medium"
|
||||
to="/aftersales"
|
||||
>{{ $t("common.back") }}</NuxtLink
|
||||
>
|
||||
</template>
|
||||
<div v-if="error" class="my-2 text-sm text-danger" role="alert">{{ error }}</div>
|
||||
<div v-if="notice" class="my-2 text-sm text-success" role="status">{{ notice }}</div>
|
||||
<div v-if="error" class="text-danger my-2 text-sm" role="alert">{{ error }}</div>
|
||||
<div v-if="notice" class="text-success my-2 text-sm" role="status">{{ notice }}</div>
|
||||
<p v-if="loading" class="text-muted">{{ $t("common.loading") }}</p>
|
||||
<template v-else-if="detail">
|
||||
<VCard>
|
||||
@@ -165,18 +175,34 @@ onMounted(() => {
|
||||
<h2 class="text-lg font-semibold">{{ $t("shop.details") }}</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<VBadge tone="blue">{{ $t(`aftersale.kind.${detail.kind}`) }}</VBadge>
|
||||
<VBadge :tone="statusClass(detail.status)">{{ $t(`aftersale.status.${detail.status}`) }}</VBadge>
|
||||
<VBadge :tone="statusClass(detail.status)">{{
|
||||
$t(`aftersale.status.${detail.status}`)
|
||||
}}</VBadge>
|
||||
<VBadge v-if="detail.reopened" tone="orange">{{ $t("aftersale.reopened") }}</VBadge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-5 grid gap-4 [grid-template-columns:repeat(auto-fit,minmax(180px,1fr))]">
|
||||
<div class="grid gap-1.5"><span class="text-sm text-muted">{{ $t("shop.created") }}</span><strong>{{ formatDate(detail.created_at) }}</strong></div>
|
||||
<div class="grid gap-1.5"><span class="text-sm text-muted">{{ $t("aftersale.buyer") }}</span><strong class="font-mono text-xs">{{ detail.user_id }}</strong></div>
|
||||
<div class="grid gap-1.5"><span class="text-sm text-muted">{{ $t("aftersale.amount") }}</span><strong>{{ fmt(detail.amount_minor, detail.currency) }}</strong></div>
|
||||
<div class="grid gap-1.5"><span class="text-sm text-muted">{{ $t("aftersale.remaining") }}</span><strong>{{ fmt(detail.remaining_refundable_minor, detail.currency) }}</strong></div>
|
||||
<div class="mb-5 grid [grid-template-columns:repeat(auto-fit,minmax(180px,1fr))] gap-4">
|
||||
<div class="grid gap-1.5">
|
||||
<span class="text-sm text-muted">{{ $t("aftersale.order") }}</span>
|
||||
<NuxtLink :to="`/orders/${detail.order_id}`" class="text-primary hover:underline">{{ $t("aftersale.viewOrder") }}</NuxtLink>
|
||||
<span class="text-muted text-sm">{{ $t("shop.created") }}</span
|
||||
><strong>{{ formatDate(detail.created_at) }}</strong>
|
||||
</div>
|
||||
<div class="grid gap-1.5">
|
||||
<span class="text-muted text-sm">{{ $t("aftersale.buyer") }}</span
|
||||
><strong class="font-mono text-xs">{{ detail.user_id }}</strong>
|
||||
</div>
|
||||
<div class="grid gap-1.5">
|
||||
<span class="text-muted text-sm">{{ $t("aftersale.amount") }}</span
|
||||
><strong>{{ fmt(detail.amount_minor, detail.currency) }}</strong>
|
||||
</div>
|
||||
<div class="grid gap-1.5">
|
||||
<span class="text-muted text-sm">{{ $t("aftersale.remaining") }}</span
|
||||
><strong>{{ fmt(detail.remaining_refundable_minor, detail.currency) }}</strong>
|
||||
</div>
|
||||
<div class="grid gap-1.5">
|
||||
<span class="text-muted text-sm">{{ $t("aftersale.order") }}</span>
|
||||
<NuxtLink :to="`/orders/${detail.order_id}`" class="text-primary hover:underline">{{
|
||||
$t("aftersale.viewOrder")
|
||||
}}</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -186,31 +212,43 @@ onMounted(() => {
|
||||
v-if="detail.item.image"
|
||||
:src="detail.item.image"
|
||||
:alt="textEn(detail.item.product_name)"
|
||||
class="h-16 w-16 rounded-md border border-border object-cover"
|
||||
class="border-border h-16 w-16 rounded-md border object-cover"
|
||||
/>
|
||||
<div class="grid gap-1">
|
||||
<strong>{{ textEn(detail.item.product_name) }}</strong>
|
||||
<span class="text-sm text-muted">{{ textZh(detail.item.product_name) }}</span>
|
||||
<span class="text-sm text-muted">{{ $t("product.skuCode") }}: {{ detail.item.sku_code }} · {{ $t("common.qty") }}: {{ detail.item.qty }} · {{ $t("common.price") }}: {{ fmt(detail.item.unit_price_minor, detail.currency) }}</span>
|
||||
<span class="text-muted text-sm">{{ textZh(detail.item.product_name) }}</span>
|
||||
<span class="text-muted text-sm"
|
||||
>{{ $t("product.skuCode") }}: {{ detail.item.sku_code }} · {{ $t("common.qty") }}:
|
||||
{{ detail.item.qty }} · {{ $t("common.price") }}:
|
||||
{{ fmt(detail.item.unit_price_minor, detail.currency) }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="mb-2 mt-5 text-base font-semibold">{{ $t("aftersale.reason") }}</h3>
|
||||
<h3 class="mt-5 mb-2 text-base font-semibold">{{ $t("aftersale.reason") }}</h3>
|
||||
<div class="grid gap-1">
|
||||
<p>{{ textEn(detail.reason) }}</p>
|
||||
<p class="text-sm text-muted">{{ textZh(detail.reason) }}</p>
|
||||
<p class="text-muted text-sm">{{ textZh(detail.reason) }}</p>
|
||||
</div>
|
||||
|
||||
<h3 class="mb-2 mt-5 text-base font-semibold">{{ $t("aftersale.evidence") }}</h3>
|
||||
<div v-if="!detail.evidence.length" class="text-muted">{{ $t("aftersale.noEvidence") }}</div>
|
||||
<h3 class="mt-5 mb-2 text-base font-semibold">{{ $t("aftersale.evidence") }}</h3>
|
||||
<div v-if="!detail.evidence.length" class="text-muted">
|
||||
{{ $t("aftersale.noEvidence") }}
|
||||
</div>
|
||||
<ul v-else class="grid gap-1">
|
||||
<li v-for="url in detail.evidence" :key="url">
|
||||
<a :href="url" target="_blank" rel="noopener noreferrer" class="break-all text-primary hover:underline">{{ url }}</a>
|
||||
<a
|
||||
:href="url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary break-all hover:underline"
|
||||
>{{ url }}</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<template v-if="detail.return_carrier || detail.return_tracking_no">
|
||||
<h3 class="mb-2 mt-5 text-base font-semibold">{{ $t("aftersale.returnShipping") }}</h3>
|
||||
<h3 class="mt-5 mb-2 text-base font-semibold">{{ $t("aftersale.returnShipping") }}</h3>
|
||||
<div class="grid gap-1">
|
||||
<div>{{ $t("shop.carrier") }}: {{ detail.return_carrier ?? "—" }}</div>
|
||||
<div>{{ $t("shop.trackingNo") }}: {{ detail.return_tracking_no ?? "—" }}</div>
|
||||
@@ -222,28 +260,54 @@ onMounted(() => {
|
||||
<h2 class="mb-4 text-lg font-semibold">{{ $t("common.actions") }}</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<template v-if="canDecide">
|
||||
<VBtn variant="primary" :disabled="busy" @click="run('approve')">{{ $t("aftersale.approve") }}</VBtn>
|
||||
<VBtn variant="danger" :disabled="busy" @click="run('reject')">{{ $t("aftersale.reject") }}</VBtn>
|
||||
<VBtn variant="primary" :disabled="busy" @click="run('approve')">{{
|
||||
$t("aftersale.approve")
|
||||
}}</VBtn>
|
||||
<VBtn variant="danger" :disabled="busy" @click="run('reject')">{{
|
||||
$t("aftersale.reject")
|
||||
}}</VBtn>
|
||||
</template>
|
||||
<VBtn v-if="canConfirmReceipt" variant="primary" :disabled="busy" @click="run('confirmReceipt')">{{ $t("aftersale.confirmReceipt") }}</VBtn>
|
||||
<VBtn v-if="canRefund" variant="primary" :disabled="busy" @click="run('refund')">{{ $t("aftersale.refund") }}</VBtn>
|
||||
<VBtn
|
||||
v-if="canConfirmReceipt"
|
||||
variant="primary"
|
||||
:disabled="busy"
|
||||
@click="run('confirmReceipt')"
|
||||
>{{ $t("aftersale.confirmReceipt") }}</VBtn
|
||||
>
|
||||
<VBtn v-if="canRefund" variant="primary" :disabled="busy" @click="run('refund')">{{
|
||||
$t("aftersale.refund")
|
||||
}}</VBtn>
|
||||
</div>
|
||||
</VCard>
|
||||
|
||||
<VCard class="mt-4">
|
||||
<h2 class="mb-4 text-lg font-semibold">{{ $t("aftersale.messages") }}</h2>
|
||||
<div v-if="!detail.messages.length" class="text-muted">{{ $t("aftersale.noMessages") }}</div>
|
||||
<div v-if="!detail.messages.length" class="text-muted">
|
||||
{{ $t("aftersale.noMessages") }}
|
||||
</div>
|
||||
<ul v-else class="grid gap-3">
|
||||
<li v-for="message in sortedMessages(detail.messages)" :key="message.id" class="rounded-md border border-border bg-bg px-3 py-2">
|
||||
<li
|
||||
v-for="message in sortedMessages(detail.messages)"
|
||||
:key="message.id"
|
||||
class="border-border bg-bg rounded-md border px-3 py-2"
|
||||
>
|
||||
<div class="mb-1 flex items-center gap-2 text-sm">
|
||||
<VBadge :tone="roleClass(message.author_role)">{{ $t(`aftersale.role.${message.author_role}`) }}</VBadge>
|
||||
<VBadge :tone="roleClass(message.author_role)">{{
|
||||
$t(`aftersale.role.${message.author_role}`)
|
||||
}}</VBadge>
|
||||
<span class="text-muted">{{ formatDate(message.created_at) }}</span>
|
||||
</div>
|
||||
<p>{{ textEn(message.content) }}</p>
|
||||
<p class="text-sm text-muted">{{ textZh(message.content) }}</p>
|
||||
<p class="text-muted text-sm">{{ textZh(message.content) }}</p>
|
||||
<ul v-if="message.evidence.length" class="mt-1 grid gap-0.5">
|
||||
<li v-for="url in message.evidence" :key="url">
|
||||
<a :href="url" target="_blank" rel="noopener noreferrer" class="break-all text-sm text-primary hover:underline">{{ url }}</a>
|
||||
<a
|
||||
:href="url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary text-sm break-all hover:underline"
|
||||
>{{ url }}</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
@@ -252,11 +316,19 @@ onMounted(() => {
|
||||
<form class="mt-5 grid gap-3" @submit.prevent="sendMessage">
|
||||
<h3 class="text-base font-semibold">{{ $t("aftersale.sendMessage") }}</h3>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<VField :label="$t('aftersale.messageEn')"><VInput v-model="messageEn" type="text" /></VField>
|
||||
<VField :label="$t('aftersale.messageZh')"><VInput v-model="messageZh" type="text" /></VField>
|
||||
<VField :label="$t('aftersale.messageEn')"
|
||||
><VInput v-model="messageEn" type="text"
|
||||
/></VField>
|
||||
<VField :label="$t('aftersale.messageZh')"
|
||||
><VInput v-model="messageZh" type="text"
|
||||
/></VField>
|
||||
</div>
|
||||
<VField :label="$t('aftersale.messageEvidence')"><VInput v-model="messageEvidence" type="text" /></VField>
|
||||
<VBtn variant="primary" type="submit" :disabled="busy">{{ $t("aftersale.sendMessage") }}</VBtn>
|
||||
<VField :label="$t('aftersale.messageEvidence')"
|
||||
><VInput v-model="messageEvidence" type="text"
|
||||
/></VField>
|
||||
<VBtn variant="primary" type="submit" :disabled="busy">{{
|
||||
$t("aftersale.sendMessage")
|
||||
}}</VBtn>
|
||||
</form>
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
@@ -48,11 +48,13 @@ onMounted(() => {
|
||||
<template>
|
||||
<VPage :title="$t('aftersale.title')">
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<label for="aftersale-status" class="text-sm font-medium">{{ $t("shop.filterStatus") }}</label>
|
||||
<label for="aftersale-status" class="text-sm font-medium">{{
|
||||
$t("shop.filterStatus")
|
||||
}}</label>
|
||||
<select
|
||||
id="aftersale-status"
|
||||
v-model="status"
|
||||
class="rounded-md border border-border bg-surface px-3 py-2 text-sm text-text focus:border-primary focus:outline-2 focus:outline-primary/30"
|
||||
class="border-border bg-surface text-text focus:border-primary focus:outline-primary/30 rounded-md border px-3 py-2 text-sm focus:outline-2"
|
||||
@change="loadAftersales"
|
||||
>
|
||||
<option value="">{{ $t("common.all") }}</option>
|
||||
@@ -65,7 +67,7 @@ onMounted(() => {
|
||||
<option value="cancelled">{{ $t("aftersale.status.cancelled") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="error" class="my-2 text-sm text-danger" role="alert">{{ error }}</div>
|
||||
<div v-if="error" class="text-danger my-2 text-sm" role="alert">{{ error }}</div>
|
||||
<p v-if="loading" class="text-muted">{{ $t("common.loading") }}</p>
|
||||
<VCard v-else-if="!aftersales.length" class="text-muted">{{ $t("common.empty") }}</VCard>
|
||||
<VTable v-else>
|
||||
@@ -81,13 +83,21 @@ onMounted(() => {
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="aftersale in aftersales" :key="aftersale.id">
|
||||
<td class="font-mono text-xs" :title="aftersale.user_id">{{ shortId(aftersale.user_id) }}</td>
|
||||
<td class="font-mono text-xs" :title="aftersale.user_id">
|
||||
{{ shortId(aftersale.user_id) }}
|
||||
</td>
|
||||
<td>{{ $t(`aftersale.kind.${aftersale.kind}`) }}</td>
|
||||
<td>{{ fmt(aftersale.amount_minor, aftersale.currency) }}</td>
|
||||
<td>{{ formatDate(aftersale.created_at) }}</td>
|
||||
<td><VBadge :tone="statusClass(aftersale.status)">{{ $t(`aftersale.status.${aftersale.status}`) }}</VBadge></td>
|
||||
<td>
|
||||
<NuxtLink :to="`/aftersales/${aftersale.id}`" class="text-primary hover:underline">{{ $t("shop.details") }}</NuxtLink>
|
||||
<VBadge :tone="statusClass(aftersale.status)">{{
|
||||
$t(`aftersale.status.${aftersale.status}`)
|
||||
}}</VBadge>
|
||||
</td>
|
||||
<td>
|
||||
<NuxtLink :to="`/aftersales/${aftersale.id}`" class="text-primary hover:underline">{{
|
||||
$t("shop.details")
|
||||
}}</NuxtLink>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
Reference in New Issue
Block a user