Merge remote-tracking branch 'github/main'
This commit is contained in:
commit
47eda40374
22 files changed
+787
-394
No files matched your search
@@ -1518,6 +1518,10 @@ const messages = {
|
||||
filters: {
|
||||
stockStatusPlaceholder: '库存状态',
|
||||
stockStatusAll: '全部库存状态',
|
||||
statusPlaceholder: '上架状态',
|
||||
statusAll: '全部上架状态',
|
||||
statusActive: '已上架',
|
||||
statusInactive: '已下架',
|
||||
wholesalePlaceholder: '批发价',
|
||||
wholesaleAll: '全部批发价',
|
||||
wholesaleEnabled: '有批发价',
|
||||
@@ -5961,6 +5965,10 @@ const messages = {
|
||||
filters: {
|
||||
stockStatusPlaceholder: '庫存狀態',
|
||||
stockStatusAll: '全部庫存狀態',
|
||||
statusPlaceholder: '上架狀態',
|
||||
statusAll: '全部上架狀態',
|
||||
statusActive: '已上架',
|
||||
statusInactive: '已下架',
|
||||
wholesalePlaceholder: '批發價',
|
||||
wholesaleAll: '全部批發價',
|
||||
wholesaleEnabled: '有批發價',
|
||||
@@ -10404,6 +10412,10 @@ const messages = {
|
||||
filters: {
|
||||
stockStatusPlaceholder: 'Stock status',
|
||||
stockStatusAll: 'All stock status',
|
||||
statusPlaceholder: 'Listing status',
|
||||
statusAll: 'All listing status',
|
||||
statusActive: 'Listed',
|
||||
statusInactive: 'Unlisted',
|
||||
wholesalePlaceholder: 'Wholesale pricing',
|
||||
wholesaleAll: 'All wholesale pricing',
|
||||
wholesaleEnabled: 'With wholesale pricing',
|
||||
|
||||
@@ -26,6 +26,7 @@ const { t, locale } = useI18n()
|
||||
const loading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const stockStatus = ref('all')
|
||||
const statusFilter = ref('all')
|
||||
const wholesaleStatus = ref('all')
|
||||
const categoryFilter = ref('all')
|
||||
const route = useRoute()
|
||||
@@ -235,6 +236,7 @@ const fetchProducts = async (options: ListFetchOptions = {}) => {
|
||||
page_size: pagination.page_size,
|
||||
search: searchQuery.value,
|
||||
stock_status: stockStatus.value,
|
||||
is_active: statusQueryValue(),
|
||||
wholesale: wholesaleStatus.value,
|
||||
category_id: categoryFilter.value === 'all' ? undefined : categoryFilter.value,
|
||||
})
|
||||
@@ -291,6 +293,13 @@ const handleSearch = () => {
|
||||
}
|
||||
const debouncedSearch = useDebounceFn(handleSearch, 300)
|
||||
|
||||
// 上架状态三态:'all' 不下发参数,'active' → is_active=1,'inactive' → is_active=0
|
||||
const statusQueryValue = () => {
|
||||
if (statusFilter.value === 'active') return '1'
|
||||
if (statusFilter.value === 'inactive') return '0'
|
||||
return undefined
|
||||
}
|
||||
|
||||
const syncWholesaleStatusFromRoute = (value: unknown) => {
|
||||
const raw = Array.isArray(value) ? value[0] : value
|
||||
const normalized = String(raw ?? '').trim().toLowerCase()
|
||||
@@ -323,6 +332,7 @@ const handleWholesaleStatusChange = () => {
|
||||
const resetFilters = () => {
|
||||
searchQuery.value = ''
|
||||
stockStatus.value = 'all'
|
||||
statusFilter.value = 'all'
|
||||
wholesaleStatus.value = 'all'
|
||||
categoryFilter.value = 'all'
|
||||
pagination.page = 1
|
||||
@@ -384,6 +394,8 @@ const toggleStatus = async (product: AdminProduct) => {
|
||||
try {
|
||||
product.is_active = newStatus
|
||||
await adminAPI.patchProduct(product.id, { is_active: newStatus })
|
||||
// 上架状态筛选生效时,该行已不满足筛选条件,需要重新拉取以免列表与筛选结果不符
|
||||
if (statusFilter.value !== 'all') fetchProducts({ preserveRows: true })
|
||||
} catch (err: any) {
|
||||
product.is_active = !newStatus
|
||||
if (isNotifiedError(err)) return
|
||||
@@ -531,6 +543,18 @@ watch(
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="w-full md:w-48">
|
||||
<Select v-model="statusFilter" @update:modelValue="handleSearch">
|
||||
<SelectTrigger class="h-9 w-full">
|
||||
<SelectValue :placeholder="t('admin.products.filters.statusPlaceholder')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{{ t('admin.products.filters.statusAll') }}</SelectItem>
|
||||
<SelectItem value="active">{{ t('admin.products.filters.statusActive') }}</SelectItem>
|
||||
<SelectItem value="inactive">{{ t('admin.products.filters.statusInactive') }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="w-full md:w-48">
|
||||
<Select v-model="wholesaleStatus" @update:modelValue="handleWholesaleStatusChange">
|
||||
<SelectTrigger class="h-9 w-full">
|
||||
|
||||
@@ -8,7 +8,7 @@ type GuestAuthInput = {
|
||||
}
|
||||
|
||||
function encodeGuestAuthorization(email: string, orderPassword: string): string {
|
||||
const bytes = new TextEncoder().encode(`${email.trim()}\n${orderPassword.trim()}`)
|
||||
const bytes = new TextEncoder().encode(`${email.trim().toLowerCase()}\n${orderPassword.trim()}`)
|
||||
let binary = ''
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte)
|
||||
|
||||
@@ -1,5 +1,61 @@
|
||||
<template>
|
||||
<div class="grid gap-[18px]">
|
||||
<!-- 子订单(含卡密) -->
|
||||
<section v-if="order.children && order.children.length > 0" class="rounded-xl border bg-card p-[22px]">
|
||||
<h2 class="mb-4 text-lg font-bold">{{ t('orderDetail.childOrdersTitle') }}</h2>
|
||||
<div class="grid gap-4">
|
||||
<div v-for="child in order.children" :key="child.id" class="rounded-lg border p-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-[13px] text-muted-foreground">{{ t('orderDetail.childOrderNo') }}:{{ child.order_no }}</div>
|
||||
<div class="text-[13px] text-muted-foreground">{{ t('orderDetail.childOrderAmount') }}:{{ formatMoney(child.total_amount, child.currency || order.currency) }}</div>
|
||||
</div>
|
||||
<Badge :variant="statusVariant(resolvedChildStatus(child))" size="sm" class="rounded-full">{{ statusLabel(resolvedChildStatus(child)) }}</Badge>
|
||||
</div>
|
||||
|
||||
<h3 class="my-2.5 mt-4 text-sm font-bold">{{ t('orderDetail.childItemsTitle') }}</h3>
|
||||
<div v-if="child.items && child.items.length" class="grid">
|
||||
<VaultOrderItem v-for="(item, cidx) in child.items" :key="cidx" :item="item" :currency="child.currency || order.currency" />
|
||||
</div>
|
||||
<div v-else class="text-[13px] text-muted-foreground">{{ t('orderDetail.noItems') }}</div>
|
||||
|
||||
<div class="mt-3.5 border-t pt-3.5">
|
||||
<VaultOrderFulfillment
|
||||
v-if="child.fulfillment"
|
||||
:title="t('orderDetail.childFulfillmentTitle')"
|
||||
:fulfillment="child.fulfillment"
|
||||
:items="child.items"
|
||||
:order-no="child.order_no || order.order_no"
|
||||
:downloading="fulfillmentDownloading"
|
||||
@download="emit('download', $event)"
|
||||
/>
|
||||
<div v-else class="text-[13px] text-muted-foreground">{{ t('orderDetail.childFulfillmentEmpty') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 主订单发货(含卡密) -->
|
||||
<section v-if="order.fulfillment" class="rounded-xl border bg-card p-[22px]">
|
||||
<h2 class="mb-4 text-lg font-bold">{{ t('orderDetail.fulfillmentTitle') }}</h2>
|
||||
<VaultOrderFulfillment
|
||||
:fulfillment="order.fulfillment"
|
||||
:items="order.items"
|
||||
:order-no="order.order_no"
|
||||
:downloading="fulfillmentDownloading"
|
||||
@download="emit('download', $event)"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- 商品 -->
|
||||
<section class="rounded-xl border bg-card p-[22px]">
|
||||
<h2 class="mb-4 text-lg font-bold">{{ t('orderDetail.itemsTitle') }}</h2>
|
||||
<div v-if="order.items && order.items.length > 0" class="grid">
|
||||
<VaultOrderItem v-for="(item, idx) in order.items" :key="idx" :item="item" :currency="order.currency" />
|
||||
</div>
|
||||
<div v-else class="text-[13px] text-muted-foreground">{{ t('orderDetail.noItems') }}</div>
|
||||
</section>
|
||||
|
||||
<!-- 金额明细 -->
|
||||
<section class="rounded-xl border bg-card p-[22px]">
|
||||
<h2 class="mb-4 text-lg font-bold">{{ t('orderDetail.amountTitle') }}</h2>
|
||||
@@ -81,62 +137,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 商品 -->
|
||||
<section class="rounded-xl border bg-card p-[22px]">
|
||||
<h2 class="mb-4 text-lg font-bold">{{ t('orderDetail.itemsTitle') }}</h2>
|
||||
<div v-if="order.items && order.items.length > 0" class="grid">
|
||||
<VaultOrderItem v-for="(item, idx) in order.items" :key="idx" :item="item" :currency="order.currency" />
|
||||
</div>
|
||||
<div v-else class="text-[13px] text-muted-foreground">{{ t('orderDetail.noItems') }}</div>
|
||||
</section>
|
||||
|
||||
<!-- 子订单 -->
|
||||
<section v-if="order.children && order.children.length > 0" class="rounded-xl border bg-card p-[22px]">
|
||||
<h2 class="mb-4 text-lg font-bold">{{ t('orderDetail.childOrdersTitle') }}</h2>
|
||||
<div class="grid gap-4">
|
||||
<div v-for="child in order.children" :key="child.id" class="rounded-lg border p-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-[13px] text-muted-foreground">{{ t('orderDetail.childOrderNo') }}:{{ child.order_no }}</div>
|
||||
<div class="text-[13px] text-muted-foreground">{{ t('orderDetail.childOrderAmount') }}:{{ formatMoney(child.total_amount, child.currency || order.currency) }}</div>
|
||||
</div>
|
||||
<Badge :variant="statusVariant(resolvedChildStatus(child))" size="sm" class="rounded-full">{{ statusLabel(resolvedChildStatus(child)) }}</Badge>
|
||||
</div>
|
||||
|
||||
<h3 class="my-2.5 mt-4 text-sm font-bold">{{ t('orderDetail.childItemsTitle') }}</h3>
|
||||
<div v-if="child.items && child.items.length" class="grid">
|
||||
<VaultOrderItem v-for="(item, cidx) in child.items" :key="cidx" :item="item" :currency="child.currency || order.currency" />
|
||||
</div>
|
||||
<div v-else class="text-[13px] text-muted-foreground">{{ t('orderDetail.noItems') }}</div>
|
||||
|
||||
<div class="mt-3.5 border-t pt-3.5">
|
||||
<VaultOrderFulfillment
|
||||
v-if="child.fulfillment"
|
||||
:title="t('orderDetail.childFulfillmentTitle')"
|
||||
:fulfillment="child.fulfillment"
|
||||
:items="child.items"
|
||||
:order-no="child.order_no || order.order_no"
|
||||
:downloading="fulfillmentDownloading"
|
||||
@download="emit('download', $event)"
|
||||
/>
|
||||
<div v-else class="text-[13px] text-muted-foreground">{{ t('orderDetail.childFulfillmentEmpty') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 主订单发货 -->
|
||||
<section v-if="order.fulfillment" class="rounded-xl border bg-card p-[22px]">
|
||||
<h2 class="mb-4 text-lg font-bold">{{ t('orderDetail.fulfillmentTitle') }}</h2>
|
||||
<VaultOrderFulfillment
|
||||
:fulfillment="order.fulfillment"
|
||||
:items="order.items"
|
||||
:order-no="order.order_no"
|
||||
:downloading="fulfillmentDownloading"
|
||||
@download="emit('download', $event)"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap justify-between gap-3.5 border-b py-3.5 first:pt-0 last:border-b-0 last:pb-0">
|
||||
<div class="flex min-w-0 flex-1 gap-3">
|
||||
<div class="flex flex-wrap justify-between gap-3.5 border-b py-3.5 first:pt-0 last:border-b-0 last:pb-0 max-[640px]:flex-col">
|
||||
<div class="flex min-w-0 flex-1 gap-3 max-[640px]:flex-none">
|
||||
<div class="grid h-[60px] w-[60px] flex-none place-items-center overflow-hidden rounded-sm border bg-secondary">
|
||||
<img
|
||||
v-if="orderItemImage(item)"
|
||||
@@ -28,7 +28,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid min-w-[180px] content-start gap-0.5 text-right text-[12.5px] text-muted-foreground max-[640px]:min-w-0 max-[640px]:text-left">
|
||||
<div class="grid min-w-[180px] content-start gap-0.5 text-right text-[12.5px] text-muted-foreground max-[640px]:min-w-0 max-[640px]:pl-[72px] max-[640px]:text-left">
|
||||
<div>{{ t('orderDetail.unitPriceLabel') }}:{{ formatMoney(item.original_unit_price, currency) }}</div>
|
||||
<div>{{ t('orderDetail.totalPriceLabel') }}:{{ formatMoney(item.original_total_price, currency) }}</div>
|
||||
<div v-if="hasDiscountAmount(item.coupon_discount_amount)" class="text-destructive">{{ t('orderDetail.couponDiscountLabel') }}:{{ formatDiscountMoney(item.coupon_discount_amount, currency) }}</div>
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<RouterLink class="grid h-10 w-10 flex-none place-items-center rounded-full bg-secondary text-muted-foreground transition-colors hover:bg-primary/10 hover:text-primary" to="/products" :aria-label="t('nav.products')"><Search class="h-[18px] w-[18px]" /></RouterLink>
|
||||
<RouterLink v-if="!userAuthStore.isAuthenticated" class="grid h-10 w-10 flex-none place-items-center rounded-full bg-secondary text-muted-foreground transition-colors hover:bg-primary/10 hover:text-primary" to="/guest/orders" :aria-label="t('navbar.guestOrders')" :title="t('navbar.guestOrders')"><ClipboardList class="h-[18px] w-[18px]" /></RouterLink>
|
||||
<button class="grid h-10 w-10 flex-none place-items-center rounded-full bg-secondary text-muted-foreground transition-colors hover:bg-primary/10 hover:text-primary" type="button" :aria-label="t('resellerConsole.common.toggleTheme')" @click="toggleTheme">
|
||||
<Sun v-if="theme === 'dark'" class="h-[18px] w-[18px]" />
|
||||
<Moon v-else class="h-[18px] w-[18px]" />
|
||||
@@ -61,15 +62,15 @@
|
||||
<!-- 移动端:更多菜单 -->
|
||||
<div class="relative hidden max-[900px]:block" ref="moreEl">
|
||||
<button class="grid h-10 w-10 flex-none place-items-center rounded-full bg-secondary text-muted-foreground transition-colors hover:bg-primary/10 hover:text-primary" type="button" :aria-label="t('navbar.more')" @click="toggleMore">
|
||||
<Menu v-if="!moreOpen" class="h-[18px] w-[18px]" />
|
||||
<X v-else class="h-[18px] w-[18px]" />
|
||||
<Menu v-if="!moreOpen" class="pointer-events-none h-[18px] w-[18px]" />
|
||||
<X v-else class="pointer-events-none h-[18px] w-[18px]" />
|
||||
</button>
|
||||
<div v-if="moreOpen" class="absolute right-0 top-[calc(100%+8px)] z-[60] flex min-w-[168px] flex-col gap-0.5 rounded-md border bg-card p-2 shadow-[var(--shadow-lg)]">
|
||||
<template v-for="item in menuItems" :key="`m-${item.key}`">
|
||||
<RouterLink v-if="item.type === 'route'" :to="item.path" class="flex w-full items-center gap-2.5 rounded-sm px-3 py-2.5 text-sm font-semibold text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground" @click="moreOpen = false">{{ item.label }}</RouterLink>
|
||||
<a v-else :href="item.path" :target="item.target" rel="noopener noreferrer" class="flex w-full items-center gap-2.5 rounded-sm px-3 py-2.5 text-sm font-semibold text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground" @click="moreOpen = false">{{ item.label }}</a>
|
||||
</template>
|
||||
<RouterLink to="/guest/orders" class="flex w-full items-center gap-2.5 rounded-sm px-3 py-2.5 text-sm font-semibold text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground" @click="moreOpen = false">{{ t('navbar.guestOrders') }}</RouterLink>
|
||||
<RouterLink v-if="!userAuthStore.isAuthenticated" to="/guest/orders" class="flex w-full items-center gap-2.5 rounded-sm px-3 py-2.5 text-sm font-semibold text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground" @click="moreOpen = false">{{ t('navbar.guestOrders') }}</RouterLink>
|
||||
<RouterLink v-if="userAuthStore.isAuthenticated" to="/me" class="flex w-full items-center gap-2.5 rounded-sm px-3 py-2.5 text-sm font-semibold text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground" @click="moreOpen = false">{{ t('navbar.personalCenter') }}</RouterLink>
|
||||
<RouterLink v-else to="/auth/login" class="flex w-full items-center gap-2.5 rounded-sm px-3 py-2.5 text-sm font-semibold text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground" @click="moreOpen = false">{{ t('navbar.login') }}</RouterLink>
|
||||
<button v-if="userAuthStore.isAuthenticated" class="flex w-full items-center gap-2.5 rounded-sm px-3 py-2.5 text-left text-sm font-semibold text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground" @click="userAuthStore.logout(); moreOpen = false">{{ t('navbar.logout') }}</button>
|
||||
@@ -109,7 +110,7 @@
|
||||
<div>
|
||||
<h4 class="mb-3 text-sm font-bold">{{ t('vault.footer.support') }}</h4>
|
||||
<RouterLink v-if="aboutEnabled" to="/about" class="flex items-center gap-[7px] py-[5px] text-[14.5px] text-muted-foreground hover:text-primary"><Info class="h-4 w-4" /> {{ t('nav.about') }}</RouterLink>
|
||||
<RouterLink to="/guest/orders" class="flex items-center gap-[7px] py-[5px] text-[14.5px] text-muted-foreground hover:text-primary"><ClipboardList class="h-4 w-4" /> {{ t('navbar.guestOrders') }}</RouterLink>
|
||||
<RouterLink v-if="!userAuthStore.isAuthenticated" to="/guest/orders" class="flex items-center gap-[7px] py-[5px] text-[14.5px] text-muted-foreground hover:text-primary"><ClipboardList class="h-4 w-4" /> {{ t('navbar.guestOrders') }}</RouterLink>
|
||||
<a v-if="contact?.telegram" :href="contact.telegram" target="_blank" rel="noopener noreferrer" class="flex items-center gap-[7px] py-[5px] text-[14.5px] text-muted-foreground hover:text-primary"><Send class="h-4 w-4" /> Telegram</a>
|
||||
<a v-if="contact?.whatsapp" :href="contact.whatsapp" target="_blank" rel="noopener noreferrer" class="flex items-center gap-[7px] py-[5px] text-[14.5px] text-muted-foreground hover:text-primary"><MessageCircle class="h-4 w-4" /> WhatsApp</a>
|
||||
</div>
|
||||
|
||||
@@ -65,157 +65,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border bg-card shadow-sm p-6">
|
||||
<h2 class="text-lg font-bold mb-4">{{ t('orderDetail.amountTitle') }}</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div class="border rounded-xl p-4">
|
||||
<div class="text-xs text-muted-foreground">{{ t('orderDetail.amountOriginal') }}</div>
|
||||
<div class="text-foreground font-mono mt-1">{{ formatMoney(order.original_amount,
|
||||
order.currency) }}</div>
|
||||
</div>
|
||||
<div class="border rounded-xl p-4">
|
||||
<div class="text-xs text-muted-foreground">{{ t('orderDetail.amountDiscount') }}</div>
|
||||
<div
|
||||
class="font-mono mt-1"
|
||||
:class="hasDiscountAmount(order.discount_amount) ? 'text-rose-600 dark:text-rose-300' : 'text-foreground'"
|
||||
>
|
||||
{{ formatDiscountMoney(order.discount_amount, order.currency) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="border rounded-xl p-4">
|
||||
<div class="text-xs text-muted-foreground">{{ t('orderDetail.amountTotal') }}</div>
|
||||
<div class="text-foreground font-mono mt-1">{{ formatMoney(order.total_amount,
|
||||
order.currency) }}</div>
|
||||
</div>
|
||||
<div v-if="hasDiscountAmount(order.member_discount_amount)" class="border border-amber-200 bg-amber-50/50 dark:border-amber-800 dark:bg-amber-950/30 rounded-xl p-4">
|
||||
<div class="text-xs text-amber-700 dark:text-amber-400">{{ t('orderDetail.amountMemberDiscount') }}</div>
|
||||
<div class="text-amber-700 dark:text-amber-400 font-mono mt-1">{{ formatDiscountMoney(order.member_discount_amount,
|
||||
order.currency) }}</div>
|
||||
</div>
|
||||
<div v-if="hasDiscountAmount(order.wholesale_discount_amount)" class="border border-emerald-200 bg-emerald-50/50 dark:border-emerald-800 dark:bg-emerald-950/30 rounded-xl p-4">
|
||||
<div class="text-xs text-emerald-700 dark:text-emerald-400">{{ t('orderDetail.amountWholesaleDiscount') }}</div>
|
||||
<div class="text-emerald-700 dark:text-emerald-400 font-mono mt-1">{{ formatDiscountMoney(order.wholesale_discount_amount,
|
||||
order.currency) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showRefundRecordsCard" class="rounded-2xl border bg-card shadow-sm p-6">
|
||||
<h2 class="text-lg font-bold mb-4">{{ t('orderDetail.refundRecordsTitle') }}</h2>
|
||||
<div v-if="refundRecords.length > 0" class="overflow-x-auto rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow class="bg-muted/50">
|
||||
<TableHead class="px-4">{{ t('orderDetail.refundRecordTime') }}</TableHead>
|
||||
<TableHead class="px-4">{{ t('orderDetail.refundRecordAmount') }}</TableHead>
|
||||
<TableHead class="px-4">{{ t('orderDetail.refundRecordReason') }}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="(record, idx) in refundRecords"
|
||||
:key="`refund-record-row-${idx}`"
|
||||
>
|
||||
<TableCell class="px-4 text-xs text-muted-foreground whitespace-nowrap">{{ formatDate(record.created_at) }}</TableCell>
|
||||
<TableCell class="px-4 font-mono text-sm text-foreground whitespace-nowrap">{{ formatMoney(record.amount, record.currency || order.currency) }}</TableCell>
|
||||
<TableCell class="px-4 text-xs text-muted-foreground whitespace-pre-wrap break-words">{{ refundReasonText(record.remark) }}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div v-else class="text-sm text-muted-foreground">{{ t('orderDetail.refundRecordsEmpty') }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showTimeCard" class="rounded-2xl border bg-card shadow-sm p-6">
|
||||
<h2 class="text-lg font-bold mb-4">{{ t('orderDetail.timeTitle') }}</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div class="border rounded-xl p-4">
|
||||
<div class="text-xs text-muted-foreground">{{ t('orderDetail.createdAtLabel') }}</div>
|
||||
<div class="text-foreground mt-1">{{ formatDate(order.created_at) }}</div>
|
||||
</div>
|
||||
<div v-if="order.paid_at" class="border rounded-xl p-4">
|
||||
<div class="text-xs text-muted-foreground">{{ t('orderDetail.paidAtLabel') }}</div>
|
||||
<div class="text-foreground mt-1">{{ formatDate(order.paid_at) }}</div>
|
||||
</div>
|
||||
<div v-if="order.expires_at" class="border rounded-xl p-4">
|
||||
<div class="text-xs text-muted-foreground">{{ t('orderDetail.expiresAtLabel') }}</div>
|
||||
<div class="text-foreground mt-1">{{ formatDate(order.expires_at) }}</div>
|
||||
</div>
|
||||
<div v-if="order.canceled_at" class="border rounded-xl p-4">
|
||||
<div class="text-xs text-muted-foreground">{{ t('orderDetail.canceledAtLabel') }}</div>
|
||||
<div class="text-foreground mt-1">{{ formatDate(order.canceled_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border bg-card shadow-sm p-6">
|
||||
<h2 class="text-lg font-bold mb-4">{{ t('orderDetail.itemsTitle') }}</h2>
|
||||
<div v-if="order.items && order.items.length > 0" class="space-y-4">
|
||||
<div v-for="(item, idx) in order.items" :key="idx"
|
||||
class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 sm:gap-4 border-b border-gray-100 pb-3 dark:border-white/5">
|
||||
<div class="flex min-w-0 items-start gap-3">
|
||||
<div class="h-14 w-14 shrink-0 overflow-hidden rounded-xl border border-gray-200 bg-white transition-all duration-200 hover:-translate-y-0.5 hover:shadow-sm dark:border-white/10 dark:bg-black/30 sm:h-16 sm:w-16">
|
||||
<img
|
||||
v-if="orderItemImage(item)"
|
||||
:src="orderItemImage(item)"
|
||||
:alt="getLocalizedText(item.title)"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<div v-else class="flex h-full w-full items-center justify-center text-gray-400 dark:text-gray-500">
|
||||
<ImageIcon class="h-5 w-5" :stroke-width="1.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-foreground font-medium">{{ getLocalizedText(item.title) }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ t('orderDetail.quantityLabel') }}:{{ item.quantity }}</div>
|
||||
<div v-if="orderItemSkuText(item)" class="text-xs text-muted-foreground mt-1">{{ t('orderDetail.itemSkuLabel') }}:{{ orderItemSkuText(item) }}</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">
|
||||
{{ t('orderDetail.itemFulfillmentLabel') }}:{{ fulfillmentTypeLabelText(item.fulfillment_type) }}
|
||||
</div>
|
||||
<div v-if="item.tags && item.tags.length" class="mt-2 flex flex-wrap gap-2">
|
||||
<Badge v-for="(tag, index) in item.tags" :key="index" variant="neutral" size="sm" class="rounded-full">{{ tag }}</Badge>
|
||||
</div>
|
||||
<div v-if="manualSubmissionRows(item.manual_form_submission, item.manual_form_schema_snapshot).length"
|
||||
class="mt-3 rounded-xl border border-gray-200 bg-white p-3 text-xs text-gray-600 dark:border-white/10 dark:bg-black/30 dark:text-gray-300">
|
||||
<div class="mb-2 font-semibold text-muted-foreground">{{ t('orderDetail.manualSubmissionTitle') }}</div>
|
||||
<div v-for="row in manualSubmissionRows(item.manual_form_submission, item.manual_form_schema_snapshot)" :key="row.key" class="mb-1 last:mb-0">
|
||||
<span class="text-foreground">{{ row.label }}</span>:{{ row.value }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0 pl-[4.25rem] sm:pl-0 text-left sm:text-right text-sm text-muted-foreground space-y-1">
|
||||
<div>{{ t('orderDetail.unitPriceLabel') }}:{{ formatMoney(item.original_unit_price, order.currency) }}</div>
|
||||
<div>{{ t('orderDetail.totalPriceLabel') }}:{{ formatMoney(item.original_total_price, order.currency) }}</div>
|
||||
<div v-if="hasDiscountAmount(item.coupon_discount_amount)">
|
||||
{{ t('orderDetail.couponDiscountLabel') }}:{{ formatDiscountMoney(item.coupon_discount_amount, order.currency)
|
||||
}}
|
||||
</div>
|
||||
<div v-if="hasDiscountAmount(item.promotion_discount_amount)">
|
||||
{{ t('orderDetail.promotionDiscountLabel') }}:{{ formatDiscountMoney(item.promotion_discount_amount,
|
||||
order.currency) }}
|
||||
</div>
|
||||
<div v-if="hasDiscountAmount(item.wholesale_discount_amount)">
|
||||
{{ t('orderDetail.wholesaleDiscountLabel') }}:{{ formatDiscountMoney(item.wholesale_discount_amount,
|
||||
order.currency) }}
|
||||
</div>
|
||||
<div v-if="hasDiscountAmount(item.member_discount_amount)">
|
||||
{{ t('orderDetail.memberDiscountLabel') }}:{{ formatDiscountMoney(item.member_discount_amount, order.currency) }}
|
||||
</div>
|
||||
<div v-if="hasItemDiscount(item)" class="font-medium text-rose-600 dark:text-rose-300">
|
||||
{{ t('orderDetail.itemDiscountTotalLabel') }}:{{ formatItemDiscountTotal(item, order.currency) }}
|
||||
</div>
|
||||
<div class="font-medium text-foreground">
|
||||
{{ t('orderDetail.itemPaidAmountLabel') }}:{{ formatItemPaidAmount(item, order.currency) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm text-muted-foreground">{{ t('orderDetail.noItems') }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="order.children && order.children.length > 0"
|
||||
class="rounded-2xl border bg-card shadow-sm p-6">
|
||||
<h2 class="text-lg font-bold mb-4">{{ t('orderDetail.childOrdersTitle') }}</h2>
|
||||
@@ -411,6 +260,157 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border bg-card shadow-sm p-6">
|
||||
<h2 class="text-lg font-bold mb-4">{{ t('orderDetail.itemsTitle') }}</h2>
|
||||
<div v-if="order.items && order.items.length > 0" class="space-y-4">
|
||||
<div v-for="(item, idx) in order.items" :key="idx"
|
||||
class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 sm:gap-4 border-b border-gray-100 pb-3 dark:border-white/5">
|
||||
<div class="flex min-w-0 items-start gap-3">
|
||||
<div class="h-14 w-14 shrink-0 overflow-hidden rounded-xl border border-gray-200 bg-white transition-all duration-200 hover:-translate-y-0.5 hover:shadow-sm dark:border-white/10 dark:bg-black/30 sm:h-16 sm:w-16">
|
||||
<img
|
||||
v-if="orderItemImage(item)"
|
||||
:src="orderItemImage(item)"
|
||||
:alt="getLocalizedText(item.title)"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<div v-else class="flex h-full w-full items-center justify-center text-gray-400 dark:text-gray-500">
|
||||
<ImageIcon class="h-5 w-5" :stroke-width="1.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-foreground font-medium">{{ getLocalizedText(item.title) }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ t('orderDetail.quantityLabel') }}:{{ item.quantity }}</div>
|
||||
<div v-if="orderItemSkuText(item)" class="text-xs text-muted-foreground mt-1">{{ t('orderDetail.itemSkuLabel') }}:{{ orderItemSkuText(item) }}</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">
|
||||
{{ t('orderDetail.itemFulfillmentLabel') }}:{{ fulfillmentTypeLabelText(item.fulfillment_type) }}
|
||||
</div>
|
||||
<div v-if="item.tags && item.tags.length" class="mt-2 flex flex-wrap gap-2">
|
||||
<Badge v-for="(tag, index) in item.tags" :key="index" variant="neutral" size="sm" class="rounded-full">{{ tag }}</Badge>
|
||||
</div>
|
||||
<div v-if="manualSubmissionRows(item.manual_form_submission, item.manual_form_schema_snapshot).length"
|
||||
class="mt-3 rounded-xl border border-gray-200 bg-white p-3 text-xs text-gray-600 dark:border-white/10 dark:bg-black/30 dark:text-gray-300">
|
||||
<div class="mb-2 font-semibold text-muted-foreground">{{ t('orderDetail.manualSubmissionTitle') }}</div>
|
||||
<div v-for="row in manualSubmissionRows(item.manual_form_submission, item.manual_form_schema_snapshot)" :key="row.key" class="mb-1 last:mb-0">
|
||||
<span class="text-foreground">{{ row.label }}</span>:{{ row.value }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0 pl-[4.25rem] sm:pl-0 text-left sm:text-right text-sm text-muted-foreground space-y-1">
|
||||
<div>{{ t('orderDetail.unitPriceLabel') }}:{{ formatMoney(item.original_unit_price, order.currency) }}</div>
|
||||
<div>{{ t('orderDetail.totalPriceLabel') }}:{{ formatMoney(item.original_total_price, order.currency) }}</div>
|
||||
<div v-if="hasDiscountAmount(item.coupon_discount_amount)">
|
||||
{{ t('orderDetail.couponDiscountLabel') }}:{{ formatDiscountMoney(item.coupon_discount_amount, order.currency)
|
||||
}}
|
||||
</div>
|
||||
<div v-if="hasDiscountAmount(item.promotion_discount_amount)">
|
||||
{{ t('orderDetail.promotionDiscountLabel') }}:{{ formatDiscountMoney(item.promotion_discount_amount,
|
||||
order.currency) }}
|
||||
</div>
|
||||
<div v-if="hasDiscountAmount(item.wholesale_discount_amount)">
|
||||
{{ t('orderDetail.wholesaleDiscountLabel') }}:{{ formatDiscountMoney(item.wholesale_discount_amount,
|
||||
order.currency) }}
|
||||
</div>
|
||||
<div v-if="hasDiscountAmount(item.member_discount_amount)">
|
||||
{{ t('orderDetail.memberDiscountLabel') }}:{{ formatDiscountMoney(item.member_discount_amount, order.currency) }}
|
||||
</div>
|
||||
<div v-if="hasItemDiscount(item)" class="font-medium text-rose-600 dark:text-rose-300">
|
||||
{{ t('orderDetail.itemDiscountTotalLabel') }}:{{ formatItemDiscountTotal(item, order.currency) }}
|
||||
</div>
|
||||
<div class="font-medium text-foreground">
|
||||
{{ t('orderDetail.itemPaidAmountLabel') }}:{{ formatItemPaidAmount(item, order.currency) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm text-muted-foreground">{{ t('orderDetail.noItems') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border bg-card shadow-sm p-6">
|
||||
<h2 class="text-lg font-bold mb-4">{{ t('orderDetail.amountTitle') }}</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div class="border rounded-xl p-4">
|
||||
<div class="text-xs text-muted-foreground">{{ t('orderDetail.amountOriginal') }}</div>
|
||||
<div class="text-foreground font-mono mt-1">{{ formatMoney(order.original_amount,
|
||||
order.currency) }}</div>
|
||||
</div>
|
||||
<div class="border rounded-xl p-4">
|
||||
<div class="text-xs text-muted-foreground">{{ t('orderDetail.amountDiscount') }}</div>
|
||||
<div
|
||||
class="font-mono mt-1"
|
||||
:class="hasDiscountAmount(order.discount_amount) ? 'text-rose-600 dark:text-rose-300' : 'text-foreground'"
|
||||
>
|
||||
{{ formatDiscountMoney(order.discount_amount, order.currency) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="border rounded-xl p-4">
|
||||
<div class="text-xs text-muted-foreground">{{ t('orderDetail.amountTotal') }}</div>
|
||||
<div class="text-foreground font-mono mt-1">{{ formatMoney(order.total_amount,
|
||||
order.currency) }}</div>
|
||||
</div>
|
||||
<div v-if="hasDiscountAmount(order.member_discount_amount)" class="border border-amber-200 bg-amber-50/50 dark:border-amber-800 dark:bg-amber-950/30 rounded-xl p-4">
|
||||
<div class="text-xs text-amber-700 dark:text-amber-400">{{ t('orderDetail.amountMemberDiscount') }}</div>
|
||||
<div class="text-amber-700 dark:text-amber-400 font-mono mt-1">{{ formatDiscountMoney(order.member_discount_amount,
|
||||
order.currency) }}</div>
|
||||
</div>
|
||||
<div v-if="hasDiscountAmount(order.wholesale_discount_amount)" class="border border-emerald-200 bg-emerald-50/50 dark:border-emerald-800 dark:bg-emerald-950/30 rounded-xl p-4">
|
||||
<div class="text-xs text-emerald-700 dark:text-emerald-400">{{ t('orderDetail.amountWholesaleDiscount') }}</div>
|
||||
<div class="text-emerald-700 dark:text-emerald-400 font-mono mt-1">{{ formatDiscountMoney(order.wholesale_discount_amount,
|
||||
order.currency) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showRefundRecordsCard" class="rounded-2xl border bg-card shadow-sm p-6">
|
||||
<h2 class="text-lg font-bold mb-4">{{ t('orderDetail.refundRecordsTitle') }}</h2>
|
||||
<div v-if="refundRecords.length > 0" class="overflow-x-auto rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow class="bg-muted/50">
|
||||
<TableHead class="px-4">{{ t('orderDetail.refundRecordTime') }}</TableHead>
|
||||
<TableHead class="px-4">{{ t('orderDetail.refundRecordAmount') }}</TableHead>
|
||||
<TableHead class="px-4">{{ t('orderDetail.refundRecordReason') }}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="(record, idx) in refundRecords"
|
||||
:key="`refund-record-row-${idx}`"
|
||||
>
|
||||
<TableCell class="px-4 text-xs text-muted-foreground whitespace-nowrap">{{ formatDate(record.created_at) }}</TableCell>
|
||||
<TableCell class="px-4 font-mono text-sm text-foreground whitespace-nowrap">{{ formatMoney(record.amount, record.currency || order.currency) }}</TableCell>
|
||||
<TableCell class="px-4 text-xs text-muted-foreground whitespace-pre-wrap break-words">{{ refundReasonText(record.remark) }}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div v-else class="text-sm text-muted-foreground">{{ t('orderDetail.refundRecordsEmpty') }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showTimeCard" class="rounded-2xl border bg-card shadow-sm p-6">
|
||||
<h2 class="text-lg font-bold mb-4">{{ t('orderDetail.timeTitle') }}</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div class="border rounded-xl p-4">
|
||||
<div class="text-xs text-muted-foreground">{{ t('orderDetail.createdAtLabel') }}</div>
|
||||
<div class="text-foreground mt-1">{{ formatDate(order.created_at) }}</div>
|
||||
</div>
|
||||
<div v-if="order.paid_at" class="border rounded-xl p-4">
|
||||
<div class="text-xs text-muted-foreground">{{ t('orderDetail.paidAtLabel') }}</div>
|
||||
<div class="text-foreground mt-1">{{ formatDate(order.paid_at) }}</div>
|
||||
</div>
|
||||
<div v-if="order.expires_at" class="border rounded-xl p-4">
|
||||
<div class="text-xs text-muted-foreground">{{ t('orderDetail.expiresAtLabel') }}</div>
|
||||
<div class="text-foreground mt-1">{{ formatDate(order.expires_at) }}</div>
|
||||
</div>
|
||||
<div v-if="order.canceled_at" class="border rounded-xl p-4">
|
||||
<div class="text-xs text-muted-foreground">{{ t('orderDetail.canceledAtLabel') }}</div>
|
||||
<div class="text-foreground mt-1">{{ formatDate(order.canceled_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -79,167 +79,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border bg-card shadow-sm p-6">
|
||||
<h2 class="text-lg font-bold mb-4">{{ t('orderDetail.amountTitle') }}</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div class="border rounded-xl p-4">
|
||||
<div class="text-xs text-muted-foreground">{{ t('orderDetail.amountOriginal') }}</div>
|
||||
<div class="text-foreground font-mono mt-1">{{ formatMoney(order.original_amount,
|
||||
order.currency) }}</div>
|
||||
</div>
|
||||
<div class="border rounded-xl p-4">
|
||||
<div class="text-xs text-muted-foreground">{{ t('orderDetail.amountDiscount') }}</div>
|
||||
<div
|
||||
class="font-mono mt-1"
|
||||
:class="hasDiscountAmount(order.discount_amount) ? 'text-rose-600 dark:text-rose-300' : 'text-foreground'"
|
||||
>
|
||||
{{ formatDiscountMoney(order.discount_amount, order.currency) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="border rounded-xl p-4">
|
||||
<div class="text-xs text-muted-foreground">{{ t('orderDetail.amountTotal') }}</div>
|
||||
<div class="text-foreground font-mono mt-1">{{ formatMoney(order.total_amount,
|
||||
order.currency) }}</div>
|
||||
</div>
|
||||
<div v-if="hasAmount(order.wallet_paid_amount)" class="border rounded-xl p-4">
|
||||
<div class="text-xs text-muted-foreground">{{ t('orderDetail.amountWalletPaid') }}</div>
|
||||
<div class="text-foreground font-mono mt-1">{{ formatMoney(order.wallet_paid_amount,
|
||||
order.currency) }}</div>
|
||||
</div>
|
||||
<div v-if="hasAmount(order.online_paid_amount)" class="border rounded-xl p-4">
|
||||
<div class="text-xs text-muted-foreground">{{ t('orderDetail.amountOnlinePaid') }}</div>
|
||||
<div class="text-foreground font-mono mt-1">{{ formatMoney(order.online_paid_amount,
|
||||
order.currency) }}</div>
|
||||
</div>
|
||||
<div v-if="hasAmount(order.refunded_amount)" class="border rounded-xl p-4">
|
||||
<div class="text-xs text-muted-foreground">{{ t('orderDetail.amountRefunded') }}</div>
|
||||
<div class="text-foreground font-mono mt-1">{{ formatMoney(order.refunded_amount,
|
||||
order.currency) }}</div>
|
||||
</div>
|
||||
<div v-if="hasDiscountAmount(order.member_discount_amount)" class="border border-amber-200 bg-amber-50/50 dark:border-amber-800 dark:bg-amber-950/30 rounded-xl p-4">
|
||||
<div class="text-xs text-amber-700 dark:text-amber-400">{{ t('orderDetail.amountMemberDiscount') }}</div>
|
||||
<div class="text-amber-700 dark:text-amber-400 font-mono mt-1">{{ formatDiscountMoney(order.member_discount_amount,
|
||||
order.currency) }}</div>
|
||||
</div>
|
||||
<div v-if="hasDiscountAmount(order.wholesale_discount_amount)" class="border border-emerald-200 bg-emerald-50/50 dark:border-emerald-800 dark:bg-emerald-950/30 rounded-xl p-4">
|
||||
<div class="text-xs text-emerald-700 dark:text-emerald-400">{{ t('orderDetail.amountWholesaleDiscount') }}</div>
|
||||
<div class="text-emerald-700 dark:text-emerald-400 font-mono mt-1">{{ formatDiscountMoney(order.wholesale_discount_amount,
|
||||
order.currency) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showRefundRecordsCard" class="rounded-2xl border bg-card shadow-sm p-6">
|
||||
<h2 class="text-lg font-bold mb-4">{{ t('orderDetail.refundRecordsTitle') }}</h2>
|
||||
<div v-if="refundRecords.length > 0" class="overflow-x-auto rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow class="bg-muted/50">
|
||||
<TableHead class="px-4">{{ t('orderDetail.refundRecordTime') }}</TableHead>
|
||||
<TableHead class="px-4">{{ t('orderDetail.refundRecordAmount') }}</TableHead>
|
||||
<TableHead class="px-4">{{ t('orderDetail.refundRecordReason') }}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="(record, idx) in refundRecords"
|
||||
:key="`refund-record-row-${idx}`"
|
||||
>
|
||||
<TableCell class="px-4 text-xs text-muted-foreground whitespace-nowrap">{{ formatDate(record.created_at) }}</TableCell>
|
||||
<TableCell class="px-4 font-mono text-sm text-foreground whitespace-nowrap">{{ formatMoney(record.amount, record.currency || order.currency) }}</TableCell>
|
||||
<TableCell class="px-4 text-xs text-muted-foreground whitespace-pre-wrap break-words">{{ refundReasonText(record.remark) }}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div v-else class="text-sm text-muted-foreground">{{ t('orderDetail.refundRecordsEmpty') }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showTimeCard" class="rounded-2xl border bg-card shadow-sm p-6">
|
||||
<h2 class="text-lg font-bold mb-4">{{ t('orderDetail.timeTitle') }}</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div class="border rounded-xl p-4">
|
||||
<div class="text-xs text-muted-foreground">{{ t('orderDetail.createdAtLabel') }}</div>
|
||||
<div class="text-foreground mt-1">{{ formatDate(order.created_at) }}</div>
|
||||
</div>
|
||||
<div v-if="order.paid_at" class="border rounded-xl p-4">
|
||||
<div class="text-xs text-muted-foreground">{{ t('orderDetail.paidAtLabel') }}</div>
|
||||
<div class="text-foreground mt-1">{{ formatDate(order.paid_at) }}</div>
|
||||
</div>
|
||||
<div v-if="order.expires_at" class="border rounded-xl p-4">
|
||||
<div class="text-xs text-muted-foreground">{{ t('orderDetail.expiresAtLabel') }}</div>
|
||||
<div class="text-foreground mt-1">{{ formatDate(order.expires_at) }}</div>
|
||||
</div>
|
||||
<div v-if="order.canceled_at" class="border rounded-xl p-4">
|
||||
<div class="text-xs text-muted-foreground">{{ t('orderDetail.canceledAtLabel') }}</div>
|
||||
<div class="text-foreground mt-1">{{ formatDate(order.canceled_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border bg-card shadow-sm p-6">
|
||||
<h2 class="text-lg font-bold mb-4">{{ t('orderDetail.itemsTitle') }}</h2>
|
||||
<div v-if="order.items && order.items.length > 0" class="space-y-4">
|
||||
<div v-for="(item, idx) in order.items" :key="idx"
|
||||
class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 sm:gap-4 border-b border-gray-100 pb-3 dark:border-white/5">
|
||||
<div class="flex min-w-0 items-start gap-3">
|
||||
<div class="h-14 w-14 shrink-0 overflow-hidden rounded-xl border border-gray-200 bg-white transition-all duration-200 hover:-translate-y-0.5 hover:shadow-sm dark:border-white/10 dark:bg-black/30 sm:h-16 sm:w-16">
|
||||
<SmartImage
|
||||
:src="orderItemImage(item)"
|
||||
:alt="getLocalizedText(item.title)"
|
||||
img-class="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-foreground font-medium">{{ getLocalizedText(item.title) }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ t('orderDetail.quantityLabel') }}:{{ item.quantity }}</div>
|
||||
<div v-if="orderItemSkuText(item)" class="text-xs text-muted-foreground mt-1">{{ t('orderDetail.itemSkuLabel') }}:{{ orderItemSkuText(item) }}</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">
|
||||
{{ t('orderDetail.itemFulfillmentLabel') }}:{{ fulfillmentTypeLabelText(item.fulfillment_type) }}
|
||||
</div>
|
||||
<div v-if="item.tags && item.tags.length" class="mt-2 flex flex-wrap gap-2">
|
||||
<Badge v-for="(tag, index) in item.tags" :key="index" variant="neutral" size="sm" class="rounded-full">{{ tag }}</Badge>
|
||||
</div>
|
||||
<div v-if="manualSubmissionRows(item.manual_form_submission, item.manual_form_schema_snapshot).length"
|
||||
class="mt-3 rounded-xl border border-gray-200 bg-white p-3 text-xs text-gray-600 dark:border-white/10 dark:bg-black/30 dark:text-gray-300">
|
||||
<div class="mb-2 font-semibold text-muted-foreground">{{ t('orderDetail.manualSubmissionTitle') }}</div>
|
||||
<div v-for="row in manualSubmissionRows(item.manual_form_submission, item.manual_form_schema_snapshot)" :key="row.key" class="mb-1 last:mb-0">
|
||||
<span class="text-foreground">{{ row.label }}</span>:{{ row.value }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0 pl-[4.25rem] sm:pl-0 text-left sm:text-right text-sm text-muted-foreground space-y-1">
|
||||
<div>{{ t('orderDetail.unitPriceLabel') }}:{{ formatMoney(item.original_unit_price, order.currency) }}</div>
|
||||
<div>{{ t('orderDetail.totalPriceLabel') }}:{{ formatMoney(item.original_total_price, order.currency) }}</div>
|
||||
<div v-if="hasDiscountAmount(item.coupon_discount_amount)">
|
||||
{{ t('orderDetail.couponDiscountLabel') }}:{{ formatDiscountMoney(item.coupon_discount_amount, order.currency)
|
||||
}}
|
||||
</div>
|
||||
<div v-if="hasDiscountAmount(item.promotion_discount_amount)">
|
||||
{{ t('orderDetail.promotionDiscountLabel') }}:{{ formatDiscountMoney(item.promotion_discount_amount,
|
||||
order.currency) }}
|
||||
</div>
|
||||
<div v-if="hasDiscountAmount(item.wholesale_discount_amount)">
|
||||
{{ t('orderDetail.wholesaleDiscountLabel') }}:{{ formatDiscountMoney(item.wholesale_discount_amount,
|
||||
order.currency) }}
|
||||
</div>
|
||||
<div v-if="hasDiscountAmount(item.member_discount_amount)">
|
||||
{{ t('orderDetail.memberDiscountLabel') }}:{{ formatDiscountMoney(item.member_discount_amount,
|
||||
order.currency) }}
|
||||
</div>
|
||||
<div v-if="hasItemDiscount(item)" class="font-medium text-rose-600 dark:text-rose-300">
|
||||
{{ t('orderDetail.itemDiscountTotalLabel') }}:{{ formatItemDiscountTotal(item, order.currency) }}
|
||||
</div>
|
||||
<div class="font-medium text-foreground">
|
||||
{{ t('orderDetail.itemPaidAmountLabel') }}:{{ formatItemPaidAmount(item, order.currency) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm text-muted-foreground">{{ t('orderDetail.noItems') }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="order.children && order.children.length > 0"
|
||||
class="rounded-2xl border bg-card shadow-sm p-6">
|
||||
<h2 class="text-lg font-bold mb-4">{{ t('orderDetail.childOrdersTitle') }}</h2>
|
||||
@@ -429,6 +268,167 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border bg-card shadow-sm p-6">
|
||||
<h2 class="text-lg font-bold mb-4">{{ t('orderDetail.itemsTitle') }}</h2>
|
||||
<div v-if="order.items && order.items.length > 0" class="space-y-4">
|
||||
<div v-for="(item, idx) in order.items" :key="idx"
|
||||
class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 sm:gap-4 border-b border-gray-100 pb-3 dark:border-white/5">
|
||||
<div class="flex min-w-0 items-start gap-3">
|
||||
<div class="h-14 w-14 shrink-0 overflow-hidden rounded-xl border border-gray-200 bg-white transition-all duration-200 hover:-translate-y-0.5 hover:shadow-sm dark:border-white/10 dark:bg-black/30 sm:h-16 sm:w-16">
|
||||
<SmartImage
|
||||
:src="orderItemImage(item)"
|
||||
:alt="getLocalizedText(item.title)"
|
||||
img-class="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-foreground font-medium">{{ getLocalizedText(item.title) }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ t('orderDetail.quantityLabel') }}:{{ item.quantity }}</div>
|
||||
<div v-if="orderItemSkuText(item)" class="text-xs text-muted-foreground mt-1">{{ t('orderDetail.itemSkuLabel') }}:{{ orderItemSkuText(item) }}</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">
|
||||
{{ t('orderDetail.itemFulfillmentLabel') }}:{{ fulfillmentTypeLabelText(item.fulfillment_type) }}
|
||||
</div>
|
||||
<div v-if="item.tags && item.tags.length" class="mt-2 flex flex-wrap gap-2">
|
||||
<Badge v-for="(tag, index) in item.tags" :key="index" variant="neutral" size="sm" class="rounded-full">{{ tag }}</Badge>
|
||||
</div>
|
||||
<div v-if="manualSubmissionRows(item.manual_form_submission, item.manual_form_schema_snapshot).length"
|
||||
class="mt-3 rounded-xl border border-gray-200 bg-white p-3 text-xs text-gray-600 dark:border-white/10 dark:bg-black/30 dark:text-gray-300">
|
||||
<div class="mb-2 font-semibold text-muted-foreground">{{ t('orderDetail.manualSubmissionTitle') }}</div>
|
||||
<div v-for="row in manualSubmissionRows(item.manual_form_submission, item.manual_form_schema_snapshot)" :key="row.key" class="mb-1 last:mb-0">
|
||||
<span class="text-foreground">{{ row.label }}</span>:{{ row.value }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0 pl-[4.25rem] sm:pl-0 text-left sm:text-right text-sm text-muted-foreground space-y-1">
|
||||
<div>{{ t('orderDetail.unitPriceLabel') }}:{{ formatMoney(item.original_unit_price, order.currency) }}</div>
|
||||
<div>{{ t('orderDetail.totalPriceLabel') }}:{{ formatMoney(item.original_total_price, order.currency) }}</div>
|
||||
<div v-if="hasDiscountAmount(item.coupon_discount_amount)">
|
||||
{{ t('orderDetail.couponDiscountLabel') }}:{{ formatDiscountMoney(item.coupon_discount_amount, order.currency)
|
||||
}}
|
||||
</div>
|
||||
<div v-if="hasDiscountAmount(item.promotion_discount_amount)">
|
||||
{{ t('orderDetail.promotionDiscountLabel') }}:{{ formatDiscountMoney(item.promotion_discount_amount,
|
||||
order.currency) }}
|
||||
</div>
|
||||
<div v-if="hasDiscountAmount(item.wholesale_discount_amount)">
|
||||
{{ t('orderDetail.wholesaleDiscountLabel') }}:{{ formatDiscountMoney(item.wholesale_discount_amount,
|
||||
order.currency) }}
|
||||
</div>
|
||||
<div v-if="hasDiscountAmount(item.member_discount_amount)">
|
||||
{{ t('orderDetail.memberDiscountLabel') }}:{{ formatDiscountMoney(item.member_discount_amount,
|
||||
order.currency) }}
|
||||
</div>
|
||||
<div v-if="hasItemDiscount(item)" class="font-medium text-rose-600 dark:text-rose-300">
|
||||
{{ t('orderDetail.itemDiscountTotalLabel') }}:{{ formatItemDiscountTotal(item, order.currency) }}
|
||||
</div>
|
||||
<div class="font-medium text-foreground">
|
||||
{{ t('orderDetail.itemPaidAmountLabel') }}:{{ formatItemPaidAmount(item, order.currency) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm text-muted-foreground">{{ t('orderDetail.noItems') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border bg-card shadow-sm p-6">
|
||||
<h2 class="text-lg font-bold mb-4">{{ t('orderDetail.amountTitle') }}</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div class="border rounded-xl p-4">
|
||||
<div class="text-xs text-muted-foreground">{{ t('orderDetail.amountOriginal') }}</div>
|
||||
<div class="text-foreground font-mono mt-1">{{ formatMoney(order.original_amount,
|
||||
order.currency) }}</div>
|
||||
</div>
|
||||
<div class="border rounded-xl p-4">
|
||||
<div class="text-xs text-muted-foreground">{{ t('orderDetail.amountDiscount') }}</div>
|
||||
<div
|
||||
class="font-mono mt-1"
|
||||
:class="hasDiscountAmount(order.discount_amount) ? 'text-rose-600 dark:text-rose-300' : 'text-foreground'"
|
||||
>
|
||||
{{ formatDiscountMoney(order.discount_amount, order.currency) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="border rounded-xl p-4">
|
||||
<div class="text-xs text-muted-foreground">{{ t('orderDetail.amountTotal') }}</div>
|
||||
<div class="text-foreground font-mono mt-1">{{ formatMoney(order.total_amount,
|
||||
order.currency) }}</div>
|
||||
</div>
|
||||
<div v-if="hasAmount(order.wallet_paid_amount)" class="border rounded-xl p-4">
|
||||
<div class="text-xs text-muted-foreground">{{ t('orderDetail.amountWalletPaid') }}</div>
|
||||
<div class="text-foreground font-mono mt-1">{{ formatMoney(order.wallet_paid_amount,
|
||||
order.currency) }}</div>
|
||||
</div>
|
||||
<div v-if="hasAmount(order.online_paid_amount)" class="border rounded-xl p-4">
|
||||
<div class="text-xs text-muted-foreground">{{ t('orderDetail.amountOnlinePaid') }}</div>
|
||||
<div class="text-foreground font-mono mt-1">{{ formatMoney(order.online_paid_amount,
|
||||
order.currency) }}</div>
|
||||
</div>
|
||||
<div v-if="hasAmount(order.refunded_amount)" class="border rounded-xl p-4">
|
||||
<div class="text-xs text-muted-foreground">{{ t('orderDetail.amountRefunded') }}</div>
|
||||
<div class="text-foreground font-mono mt-1">{{ formatMoney(order.refunded_amount,
|
||||
order.currency) }}</div>
|
||||
</div>
|
||||
<div v-if="hasDiscountAmount(order.member_discount_amount)" class="border border-amber-200 bg-amber-50/50 dark:border-amber-800 dark:bg-amber-950/30 rounded-xl p-4">
|
||||
<div class="text-xs text-amber-700 dark:text-amber-400">{{ t('orderDetail.amountMemberDiscount') }}</div>
|
||||
<div class="text-amber-700 dark:text-amber-400 font-mono mt-1">{{ formatDiscountMoney(order.member_discount_amount,
|
||||
order.currency) }}</div>
|
||||
</div>
|
||||
<div v-if="hasDiscountAmount(order.wholesale_discount_amount)" class="border border-emerald-200 bg-emerald-50/50 dark:border-emerald-800 dark:bg-emerald-950/30 rounded-xl p-4">
|
||||
<div class="text-xs text-emerald-700 dark:text-emerald-400">{{ t('orderDetail.amountWholesaleDiscount') }}</div>
|
||||
<div class="text-emerald-700 dark:text-emerald-400 font-mono mt-1">{{ formatDiscountMoney(order.wholesale_discount_amount,
|
||||
order.currency) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showRefundRecordsCard" class="rounded-2xl border bg-card shadow-sm p-6">
|
||||
<h2 class="text-lg font-bold mb-4">{{ t('orderDetail.refundRecordsTitle') }}</h2>
|
||||
<div v-if="refundRecords.length > 0" class="overflow-x-auto rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow class="bg-muted/50">
|
||||
<TableHead class="px-4">{{ t('orderDetail.refundRecordTime') }}</TableHead>
|
||||
<TableHead class="px-4">{{ t('orderDetail.refundRecordAmount') }}</TableHead>
|
||||
<TableHead class="px-4">{{ t('orderDetail.refundRecordReason') }}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="(record, idx) in refundRecords"
|
||||
:key="`refund-record-row-${idx}`"
|
||||
>
|
||||
<TableCell class="px-4 text-xs text-muted-foreground whitespace-nowrap">{{ formatDate(record.created_at) }}</TableCell>
|
||||
<TableCell class="px-4 font-mono text-sm text-foreground whitespace-nowrap">{{ formatMoney(record.amount, record.currency || order.currency) }}</TableCell>
|
||||
<TableCell class="px-4 text-xs text-muted-foreground whitespace-pre-wrap break-words">{{ refundReasonText(record.remark) }}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div v-else class="text-sm text-muted-foreground">{{ t('orderDetail.refundRecordsEmpty') }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showTimeCard" class="rounded-2xl border bg-card shadow-sm p-6">
|
||||
<h2 class="text-lg font-bold mb-4">{{ t('orderDetail.timeTitle') }}</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div class="border rounded-xl p-4">
|
||||
<div class="text-xs text-muted-foreground">{{ t('orderDetail.createdAtLabel') }}</div>
|
||||
<div class="text-foreground mt-1">{{ formatDate(order.created_at) }}</div>
|
||||
</div>
|
||||
<div v-if="order.paid_at" class="border rounded-xl p-4">
|
||||
<div class="text-xs text-muted-foreground">{{ t('orderDetail.paidAtLabel') }}</div>
|
||||
<div class="text-foreground mt-1">{{ formatDate(order.paid_at) }}</div>
|
||||
</div>
|
||||
<div v-if="order.expires_at" class="border rounded-xl p-4">
|
||||
<div class="text-xs text-muted-foreground">{{ t('orderDetail.expiresAtLabel') }}</div>
|
||||
<div class="text-foreground mt-1">{{ formatDate(order.expires_at) }}</div>
|
||||
</div>
|
||||
<div v-if="order.canceled_at" class="border rounded-xl p-4">
|
||||
<div class="text-xs text-muted-foreground">{{ t('orderDetail.canceledAtLabel') }}</div>
|
||||
<div class="text-foreground mt-1">{{ formatDate(order.canceled_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -170,7 +170,8 @@ func (s *Service) GetPublicBySlugForTenant(tenant reseller.TenantContext, resell
|
||||
}
|
||||
|
||||
// ListAdmin 获取后台商品列表
|
||||
func (s *Service) ListAdmin(categoryID, search, fulfillmentType, stockStatus string, hasWholesalePrices *bool, lowStockThreshold int, page, pageSize int) ([]productdomain.Product, int64, error) {
|
||||
// isActive 为三态上架筛选:nil=不限,true=仅已上架,false=仅已下架。
|
||||
func (s *Service) ListAdmin(categoryID, search, fulfillmentType, stockStatus string, hasWholesalePrices, isActive *bool, lowStockThreshold int, page, pageSize int) ([]productdomain.Product, int64, error) {
|
||||
filter := productcontract.ListFilter{
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
@@ -179,6 +180,7 @@ func (s *Service) ListAdmin(categoryID, search, fulfillmentType, stockStatus str
|
||||
FulfillmentType: strings.TrimSpace(fulfillmentType),
|
||||
StockStatus: normalizeStockStatus(stockStatus),
|
||||
HasWholesalePrices: hasWholesalePrices,
|
||||
IsActive: isActive,
|
||||
LowStockThreshold: lowStockThreshold,
|
||||
OnlyActive: false,
|
||||
WithCategory: true,
|
||||
|
||||
@@ -7,6 +7,9 @@ import (
|
||||
)
|
||||
|
||||
// ListFilter 描述 Catalog 商品列表的持久化筛选条件。
|
||||
//
|
||||
// IsActive 与 OnlyActive 都是上架状态条件,分工不同:OnlyActive 是公开侧强制的
|
||||
// 「必须上架」开关;IsActive 是后台可选的三态筛选,nil 不限、true 仅已上架、false 仅已下架。
|
||||
type ListFilter struct {
|
||||
Page int
|
||||
PageSize int
|
||||
@@ -19,6 +22,7 @@ type ListFilter struct {
|
||||
HasWholesalePrices *bool
|
||||
LowStockThreshold int
|
||||
OnlyActive bool
|
||||
IsActive *bool
|
||||
WithCategory bool
|
||||
UpdatedAfter *time.Time
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -385,3 +386,77 @@ func TestUpdateProductWholesalePricesHandlerReturnsNotFound(t *testing.T) {
|
||||
t.Fatalf("expected product not found response, got body=%s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 后台商品列表的上架状态筛选:is_active=1 只看已上架,is_active=0 只看已下架。
|
||||
func TestAdminProductListFiltersByActiveState(t *testing.T) {
|
||||
h, db := setupAdminProductHandlerTest(t)
|
||||
|
||||
seed := func(slug string, isActive bool) productdomain.Product {
|
||||
row := productdomain.Product{
|
||||
CategoryID: 1,
|
||||
Slug: slug,
|
||||
TitleJSON: jsonmap.JSON{"zh-CN": "上架状态筛选商品"},
|
||||
PriceAmount: money.FromDecimal(decimal.NewFromInt(10)),
|
||||
FulfillmentType: constants.FulfillmentTypeManual,
|
||||
ManualStockTotal: 5,
|
||||
IsActive: isActive,
|
||||
}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatalf("create product %s failed: %v", slug, err)
|
||||
}
|
||||
return row
|
||||
}
|
||||
active := seed("api-state-active", true)
|
||||
inactive := seed("api-state-inactive", false)
|
||||
|
||||
listSlugs := func(query string) map[string]bool {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/products?"+query, nil)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
|
||||
h.GetAdminProducts(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200 for %q, got %d body=%s", query, w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Data []productdomain.Product `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response failed: %v body=%s", err, w.Body.String())
|
||||
}
|
||||
got := make(map[string]bool, len(resp.Data))
|
||||
for _, row := range resp.Data {
|
||||
got[row.Slug] = true
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
got := listSlugs("is_active=1")
|
||||
if !got[active.Slug] || got[inactive.Slug] {
|
||||
t.Fatalf("is_active=1 should return only active rows, got %+v", got)
|
||||
}
|
||||
|
||||
got = listSlugs("is_active=0")
|
||||
if !got[inactive.Slug] || got[active.Slug] {
|
||||
t.Fatalf("is_active=0 should return only inactive rows, got %+v", got)
|
||||
}
|
||||
|
||||
got = listSlugs("is_active=all")
|
||||
if !got[active.Slug] || !got[inactive.Slug] {
|
||||
t.Fatalf("is_active=all should return both rows, got %+v", got)
|
||||
}
|
||||
|
||||
// 与其他条件联动:is_active=0 叠加 search,仍应 AND 生效
|
||||
got = listSlugs("is_active=0&search=" + url.QueryEscape("上架状态筛选"))
|
||||
if !got[inactive.Slug] || got[active.Slug] {
|
||||
t.Fatalf("is_active=0 + search should still exclude active rows, got %+v", got)
|
||||
}
|
||||
|
||||
got = listSlugs("is_active=0&search=" + url.QueryEscape("绝不匹配的关键词"))
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("is_active=0 + non-matching search should be empty, got %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,10 @@ func (r *ProductStore) List(filter productcontract.ListFilter) ([]productdomain.
|
||||
} else if filter.CategoryID != "" {
|
||||
query = query.Where("category_id = ?", filter.CategoryID)
|
||||
}
|
||||
// 三态上架筛选:OnlyActive 已强制 is_active = true,此时不再叠加
|
||||
if filter.IsActive != nil && !filter.OnlyActive {
|
||||
query = query.Where("products.is_active = ?", *filter.IsActive)
|
||||
}
|
||||
if len(filter.ExcludeProductIDs) > 0 {
|
||||
query = query.Where("products.id NOT IN ?", filter.ExcludeProductIDs)
|
||||
}
|
||||
|
||||
@@ -580,3 +580,131 @@ func TestProductRepositoryListFiltersWholesalePrices(t *testing.T) {
|
||||
t.Fatalf("product without wholesale missing: %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func createActiveStateProduct(t *testing.T, repo *ProductStore, slug string, title string, isActive bool, wholesale bool) *productdomain.Product {
|
||||
t.Helper()
|
||||
product := &productdomain.Product{
|
||||
CategoryID: 1,
|
||||
Slug: slug,
|
||||
TitleJSON: jsonmap.JSON{"zh-CN": title},
|
||||
PriceAmount: money.FromDecimal(decimal.NewFromInt(100)),
|
||||
PurchaseType: constants.ProductPurchaseMember,
|
||||
FulfillmentType: constants.FulfillmentTypeManual,
|
||||
ManualStockTotal: 10,
|
||||
IsActive: isActive,
|
||||
}
|
||||
if wholesale {
|
||||
product.WholesalePrices = productdomain.WholesalePriceTiers{{MinQuantity: 5, UnitPrice: money.FromDecimal(decimal.NewFromInt(80))}}
|
||||
}
|
||||
if err := repo.Create(product); err != nil {
|
||||
t.Fatalf("create product %s failed: %v", slug, err)
|
||||
}
|
||||
return product
|
||||
}
|
||||
|
||||
func listSlugs(t *testing.T, repo *ProductStore, filter productcontract.ListFilter) map[string]bool {
|
||||
t.Helper()
|
||||
if filter.Page == 0 {
|
||||
filter.Page = 1
|
||||
}
|
||||
if filter.PageSize == 0 {
|
||||
filter.PageSize = 50
|
||||
}
|
||||
rows, _, err := repo.List(filter)
|
||||
if err != nil {
|
||||
t.Fatalf("list products failed: %v", err)
|
||||
}
|
||||
got := make(map[string]bool, len(rows))
|
||||
for _, row := range rows {
|
||||
got[row.Slug] = true
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
// 上架状态筛选是三态的:nil 不限、true 仅已上架、false 仅已下架。
|
||||
func TestProductRepositoryListFiltersActiveState(t *testing.T) {
|
||||
repo, _ := setupProductStoreTest(t)
|
||||
|
||||
active := createActiveStateProduct(t, repo, "state-active", "上架商品", true, false)
|
||||
inactive := createActiveStateProduct(t, repo, "state-inactive", "下架商品", false, false)
|
||||
|
||||
onlyActive := true
|
||||
got := listSlugs(t, repo, productcontract.ListFilter{IsActive: &onlyActive})
|
||||
if !got[active.Slug] || got[inactive.Slug] {
|
||||
t.Fatalf("IsActive=true should return only the active product, got %+v", got)
|
||||
}
|
||||
|
||||
onlyInactive := false
|
||||
got = listSlugs(t, repo, productcontract.ListFilter{IsActive: &onlyInactive})
|
||||
if !got[inactive.Slug] || got[active.Slug] {
|
||||
t.Fatalf("IsActive=false should return only the inactive product, got %+v", got)
|
||||
}
|
||||
|
||||
got = listSlugs(t, repo, productcontract.ListFilter{})
|
||||
if !got[active.Slug] || !got[inactive.Slug] {
|
||||
t.Fatalf("IsActive=nil should return both products, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 上架状态必须与搜索、批发价、库存状态等条件按 AND 组合,不能互相覆盖。
|
||||
func TestProductRepositoryListCombinesActiveStateWithOtherFilters(t *testing.T) {
|
||||
repo, _ := setupProductStoreTest(t)
|
||||
|
||||
activeHit := createActiveStateProduct(t, repo, "combo-active-hit", "组合命中商品", true, true)
|
||||
inactiveHit := createActiveStateProduct(t, repo, "combo-inactive-hit", "组合命中商品", false, true)
|
||||
inactiveNoWholesale := createActiveStateProduct(t, repo, "combo-inactive-nowholesale", "组合命中商品", false, false)
|
||||
unrelated := createActiveStateProduct(t, repo, "other-active", "无关商品", true, true)
|
||||
|
||||
onlyInactive := false
|
||||
withWholesale := true
|
||||
|
||||
// 下架 + 有批发价:只剩 inactiveHit
|
||||
got := listSlugs(t, repo, productcontract.ListFilter{IsActive: &onlyInactive, HasWholesalePrices: &withWholesale})
|
||||
if !got[inactiveHit.Slug] || got[inactiveNoWholesale.Slug] || got[activeHit.Slug] || got[unrelated.Slug] {
|
||||
t.Fatalf("inactive+wholesale combination mismatch: %+v", got)
|
||||
}
|
||||
|
||||
// 下架 + 搜索:命中两个下架商品,且排除已上架的 activeHit
|
||||
got = listSlugs(t, repo, productcontract.ListFilter{IsActive: &onlyInactive, Search: "组合命中"})
|
||||
if !got[inactiveHit.Slug] || !got[inactiveNoWholesale.Slug] || got[activeHit.Slug] || got[unrelated.Slug] {
|
||||
t.Fatalf("inactive+search combination mismatch: %+v", got)
|
||||
}
|
||||
|
||||
// 已上架 + 搜索:只剩 activeHit
|
||||
onlyActive := true
|
||||
got = listSlugs(t, repo, productcontract.ListFilter{IsActive: &onlyActive, Search: "组合命中"})
|
||||
if !got[activeHit.Slug] || len(got) != 1 {
|
||||
t.Fatalf("active+search combination mismatch: %+v", got)
|
||||
}
|
||||
|
||||
// 下架 + 库存正常:manual 商品剩余 10 > 0,两个下架商品都命中;再叠加批发价收窄到 1 个
|
||||
got = listSlugs(t, repo, productcontract.ListFilter{
|
||||
IsActive: &onlyInactive,
|
||||
StockStatus: "normal",
|
||||
LowStockThreshold: 5,
|
||||
HasWholesalePrices: &withWholesale,
|
||||
})
|
||||
if !got[inactiveHit.Slug] || len(got) != 1 {
|
||||
t.Fatalf("inactive+stock+wholesale combination mismatch: %+v", got)
|
||||
}
|
||||
|
||||
// 搜索无命中时,即使上架状态匹配也必须返回空集
|
||||
got = listSlugs(t, repo, productcontract.ListFilter{IsActive: &onlyInactive, Search: "不存在的关键词"})
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("non-matching search should return nothing, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// OnlyActive 是公开侧强制的「必须上架」,不应被 IsActive=false 反向覆盖成空集。
|
||||
func TestProductRepositoryListOnlyActiveTakesPrecedenceOverIsActive(t *testing.T) {
|
||||
repo, _ := setupProductStoreTest(t)
|
||||
|
||||
active := createActiveStateProduct(t, repo, "precedence-active", "上架商品", true, false)
|
||||
createActiveStateProduct(t, repo, "precedence-inactive", "下架商品", false, false)
|
||||
|
||||
onlyInactive := false
|
||||
got := listSlugs(t, repo, productcontract.ListFilter{OnlyActive: true, IsActive: &onlyInactive})
|
||||
if !got[active.Slug] || len(got) != 1 {
|
||||
t.Fatalf("OnlyActive should win over IsActive, got %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
|
||||
// ProductQueries 是后台商品列表/详情读取所需的最小用例接口。
|
||||
type ProductQueries interface {
|
||||
ListAdmin(categoryID, search, fulfillmentType, stockStatus string, hasWholesalePrices *bool, lowStockThreshold int, page, pageSize int) ([]productdomain.Product, int64, error)
|
||||
ListAdmin(categoryID, search, fulfillmentType, stockStatus string, hasWholesalePrices, isActive *bool, lowStockThreshold int, page, pageSize int) ([]productdomain.Product, int64, error)
|
||||
GetAdminByID(id string) (*productdomain.Product, error)
|
||||
ApplyAutoStockCounts(products []productdomain.Product) error
|
||||
}
|
||||
@@ -98,24 +98,31 @@ func (h *AdminProductHandler) GetAdminProducts(c *gin.Context) {
|
||||
if stockStatus == "" {
|
||||
stockStatus = c.Query("stock_staus")
|
||||
}
|
||||
hasWholesalePrices, err := parseWholesaleFilter(c.Query("wholesale"))
|
||||
hasWholesalePrices, err := parseTriStateBoolFilter(c.Query("wholesale"))
|
||||
if err != nil {
|
||||
ginutil.RespondError(c, response.CodeBadRequest, "error.bad_request", err)
|
||||
return
|
||||
}
|
||||
if hasWholesalePrices == nil {
|
||||
hasWholesalePrices, err = parseWholesaleFilter(c.Query("has_wholesale_prices"))
|
||||
hasWholesalePrices, err = parseTriStateBoolFilter(c.Query("has_wholesale_prices"))
|
||||
if err != nil {
|
||||
ginutil.RespondError(c, response.CodeBadRequest, "error.bad_request", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
// 三态上架筛选:?is_active=1 仅已上架,?is_active=0 仅已下架,缺省/ all 不限。
|
||||
// 与 search / category_id / stock_status / wholesale 之间是 AND 关系,可任意组合。
|
||||
isActive, err := parseTriStateBoolFilter(c.Query("is_active"))
|
||||
if err != nil {
|
||||
ginutil.RespondError(c, response.CodeBadRequest, "error.bad_request", err)
|
||||
return
|
||||
}
|
||||
|
||||
lowStockThreshold := 0
|
||||
if h.settings != nil {
|
||||
lowStockThreshold = h.settings.GetDashboardLowStockThreshold()
|
||||
}
|
||||
products, total, err := h.products.ListAdmin(categoryID, search, fulfillmentType, stockStatus, hasWholesalePrices, lowStockThreshold, page, pageSize)
|
||||
products, total, err := h.products.ListAdmin(categoryID, search, fulfillmentType, stockStatus, hasWholesalePrices, isActive, lowStockThreshold, page, pageSize)
|
||||
if err != nil {
|
||||
ginutil.RespondError(c, response.CodeInternal, "error.product_fetch_failed", err)
|
||||
return
|
||||
@@ -132,7 +139,7 @@ func (h *AdminProductHandler) GetAdminProducts(c *gin.Context) {
|
||||
response.SuccessWithPage(c, products, pagination)
|
||||
}
|
||||
|
||||
func parseWholesaleFilter(raw string) (*bool, error) {
|
||||
func parseTriStateBoolFilter(raw string) (*bool, error) {
|
||||
value := strings.ToLower(strings.TrimSpace(raw))
|
||||
switch value {
|
||||
case "", "all":
|
||||
|
||||
@@ -15,6 +15,8 @@ func (r *Store) GetTopChannels(startAt, endAt time.Time, limit int) ([]dashboard
|
||||
limit = 5
|
||||
}
|
||||
rows := make([]dashboard.ChannelRankingRow, 0)
|
||||
timeWhere, timeArgs := timeRangeQuery(r.db, "payments.created_at", startAt, endAt)
|
||||
args := append(timeArgs, constants.PaymentProviderWallet)
|
||||
if err := r.db.Model(&paymentdomain.Payment{}).
|
||||
Select(`
|
||||
payments.channel_id as channel_id,
|
||||
@@ -27,7 +29,7 @@ func (r *Store) GetTopChannels(startAt, endAt time.Time, limit int) ([]dashboard
|
||||
`).
|
||||
Joins("LEFT JOIN payment_channels ON payment_channels.id = payments.channel_id").
|
||||
Where("payments.deleted_at IS NULL").
|
||||
Where("payments.created_at >= ? AND payments.created_at < ? AND payments.provider_type <> ?", startAt, endAt, constants.PaymentProviderWallet).
|
||||
Where(timeWhere+" AND payments.provider_type <> ?", args...).
|
||||
Group("payments.channel_id, payment_channels.name, payments.provider_type, payments.channel_type").
|
||||
Order("success_amount DESC, success_count DESC").
|
||||
Limit(limit).
|
||||
|
||||
@@ -47,9 +47,10 @@ func (r *Store) GetOverview(startAt, endAt time.Time) (dashboard.OverviewRow, er
|
||||
COALESCE(SUM(CASE WHEN status IN (%s) THEN total_amount ELSE 0 END), 0) as gmv_paid
|
||||
`, paidIn, constants.OrderStatusCompleted, constants.OrderStatusPendingPayment, processingIn, paidIn)
|
||||
|
||||
timeWhere, timeArgs := timeRangeQuery(r.db, "created_at", startAt, endAt)
|
||||
if err := r.db.Model(&orderdomain.Order{}).
|
||||
Select(orderSelectSQL).
|
||||
Where("deleted_at IS NULL AND parent_id IS NULL AND created_at >= ? AND created_at < ?", startAt, endAt).
|
||||
Where("deleted_at IS NULL AND parent_id IS NULL AND "+timeWhere, timeArgs...).
|
||||
Scan(&orderAgg).Error; err != nil {
|
||||
return result, err
|
||||
}
|
||||
@@ -87,14 +88,22 @@ func (r *Store) GetOverview(startAt, endAt time.Time) (dashboard.OverviewRow, er
|
||||
return result, err
|
||||
}
|
||||
|
||||
timeWhere2, timeArgs2 := timeRangeQuery(r.db, "created_at", startAt, endAt)
|
||||
if err := r.db.Model(&userdomain.User{}).
|
||||
Where("deleted_at IS NULL AND "+timeWhere2, timeArgs2...).
|
||||
Count(&result.NewUsers).Error; err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
if err := r.db.Model(&productdomain.Product{}).
|
||||
Where("deleted_at IS NULL AND is_active = ?", true).
|
||||
Count(&result.ActiveProducts).Error; err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
timeWhere3, timeArgs3 := timeRangeQuery(r.db, "created_at", startAt, endAt)
|
||||
_ = r.db.Model(&orderdomain.Order{}).
|
||||
Where("deleted_at IS NULL AND parent_id IS NULL AND created_at >= ? AND created_at < ? AND currency <> ''", startAt, endAt).
|
||||
Where("deleted_at IS NULL AND parent_id IS NULL AND "+timeWhere3+" AND currency <> ''", timeArgs3...).
|
||||
Order("id DESC").
|
||||
Limit(1).
|
||||
Pluck("currency", &result.Currency).Error
|
||||
@@ -114,8 +123,9 @@ func (r *Store) GetOverview(startAt, endAt time.Time) (dashboard.OverviewRow, er
|
||||
func (r *Store) GetPaymentOrderAlertCounts(startAt, endAt time.Time) (dashboard.PaymentOrderAlertCountsRow, error) {
|
||||
result := dashboard.PaymentOrderAlertCountsRow{}
|
||||
|
||||
timeWhere, timeArgs := timeRangeQuery(r.db, "created_at", startAt, endAt)
|
||||
if err := r.db.Model(&orderdomain.Order{}).
|
||||
Where("deleted_at IS NULL AND parent_id IS NULL AND status = ? AND created_at >= ? AND created_at < ?", constants.OrderStatusPendingPayment, startAt, endAt).
|
||||
Where("deleted_at IS NULL AND parent_id IS NULL AND status = ? AND "+timeWhere, append([]interface{}{constants.OrderStatusPendingPayment}, timeArgs...)...).
|
||||
Count(&result.PendingPaymentOrders).Error; err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ func (r *Store) GetTopProducts(startAt, endAt time.Time, limit int) ([]dashboard
|
||||
}
|
||||
rows := make([]dashboard.ProductRankingRow, 0)
|
||||
titleExpr := localizedJSONCoalesceExpr(r.db, "order_items.title_json")
|
||||
timeWhere, timeArgs := timeRangeQuery(r.db, "orders.created_at", startAt, endAt)
|
||||
args := append(timeArgs, paidOrderStatuses())
|
||||
if err := r.db.Model(&orderdomain.OrderItem{}).
|
||||
Select(fmt.Sprintf(`
|
||||
order_items.product_id as product_id,
|
||||
@@ -30,7 +32,7 @@ func (r *Store) GetTopProducts(startAt, endAt time.Time, limit int) ([]dashboard
|
||||
`, titleExpr)).
|
||||
Joins("JOIN orders ON orders.id = order_items.order_id").
|
||||
Joins("LEFT JOIN product_skus ON product_skus.id = order_items.sku_id AND product_skus.deleted_at IS NULL").
|
||||
Where("order_items.deleted_at IS NULL AND orders.deleted_at IS NULL AND orders.created_at >= ? AND orders.created_at < ? AND orders.status IN ?", startAt, endAt, paidOrderStatuses()).
|
||||
Where("order_items.deleted_at IS NULL AND orders.deleted_at IS NULL AND "+timeWhere+" AND orders.status IN ?", args...).
|
||||
// 注意:不能把 product_skus.spec_values_json 直接放进 GROUP BY —— 在 Postgres 下 json 列没有等值运算符会报错。
|
||||
// 通过 GROUP BY 产品主键 product_skus.id,利用 PK 函数依赖让 Postgres 允许 SELECT sku_code/spec_values_json 不必聚合。
|
||||
Group("order_items.product_id, order_items.sku_id, product_skus.id, title").
|
||||
|
||||
@@ -37,6 +37,7 @@ type refundAggregateRow struct {
|
||||
func (r *Store) getRefundAdjustments(startAt, endAt time.Time) ([]refundAdjustmentRow, error) {
|
||||
refundDayExpr := dateGroupExpr(r.db, "order_refund_records.created_at", startAt.Location(), startAt)
|
||||
refundRows := make([]refundAggregateRow, 0)
|
||||
timeWhere, timeArgs := timeRangeQuery(r.db, "order_refund_records.created_at", startAt, endAt)
|
||||
if err := r.db.Model(&orderdomain.OrderRefundRecord{}).
|
||||
Select(fmt.Sprintf(`
|
||||
%s as day,
|
||||
@@ -44,7 +45,7 @@ func (r *Store) getRefundAdjustments(startAt, endAt time.Time) ([]refundAdjustme
|
||||
COALESCE(SUM(order_refund_records.amount), 0) as refund_amount,
|
||||
COALESCE(SUM(order_refund_records.payment_fee_refunded_amount), 0) as payment_fee_refunded
|
||||
`, refundDayExpr)).
|
||||
Where("order_refund_records.deleted_at IS NULL AND order_refund_records.created_at >= ? AND order_refund_records.created_at < ?", startAt, endAt).
|
||||
Where("order_refund_records.deleted_at IS NULL AND "+timeWhere, timeArgs...).
|
||||
Group(fmt.Sprintf("%s, order_refund_records.order_id", refundDayExpr)).
|
||||
Scan(&refundRows).Error; err != nil {
|
||||
return nil, err
|
||||
@@ -129,13 +130,15 @@ func (r *Store) getRefundAdjustments(startAt, endAt time.Time) ([]refundAdjustme
|
||||
// GetProfitOverview 获取利润总览统计
|
||||
func (r *Store) GetProfitOverview(startAt, endAt time.Time) (dashboard.ProfitOverviewRow, error) {
|
||||
result := dashboard.ProfitOverviewRow{}
|
||||
timeWhere, timeArgs := timeRangeQuery(r.db, "orders.created_at", startAt, endAt)
|
||||
args := append(timeArgs, profitOrderStatuses())
|
||||
if err := r.db.Model(&orderdomain.OrderItem{}).
|
||||
Select(`
|
||||
COALESCE(SUM(order_items.total_price - order_items.coupon_discount), 0) as total_revenue,
|
||||
COALESCE(SUM(order_items.cost_price * order_items.quantity), 0) as total_cost
|
||||
`).
|
||||
Joins("JOIN orders ON orders.id = order_items.order_id").
|
||||
Where("order_items.deleted_at IS NULL AND orders.deleted_at IS NULL AND orders.created_at >= ? AND orders.created_at < ? AND orders.status IN ?", startAt, endAt, profitOrderStatuses()).
|
||||
Where("order_items.deleted_at IS NULL AND orders.deleted_at IS NULL AND "+timeWhere+" AND orders.status IN ?", args...).
|
||||
Scan(&result).Error; err != nil {
|
||||
return result, err
|
||||
}
|
||||
@@ -150,9 +153,11 @@ func (r *Store) GetProfitOverview(startAt, endAt time.Time) (dashboard.ProfitOve
|
||||
result.RefundedCost += adjustment.RefundedCost
|
||||
paymentFeeRefunded += adjustment.PaymentFeeRefunded
|
||||
}
|
||||
paymentTimeWhere, paymentTimeArgs := timeRangeQuery(r.db, "created_at", startAt, endAt)
|
||||
paymentArgs := append(paymentTimeArgs, constants.PaymentStatusSuccess, constants.PaymentProviderWallet, constants.PaymentFeePolicyMerchantAbsorbed)
|
||||
if err := r.db.Model(&paymentdomain.Payment{}).
|
||||
Select("COALESCE(SUM(fee_amount), 0)").
|
||||
Where("deleted_at IS NULL AND created_at >= ? AND created_at < ? AND status = ? AND provider_type <> ? AND fee_policy = ?", startAt, endAt, constants.PaymentStatusSuccess, constants.PaymentProviderWallet, constants.PaymentFeePolicyMerchantAbsorbed).
|
||||
Where("deleted_at IS NULL AND "+paymentTimeWhere+" AND status = ? AND provider_type <> ? AND fee_policy = ?", paymentArgs...).
|
||||
Scan(&result.PaymentFee).Error; err != nil {
|
||||
return result, err
|
||||
}
|
||||
@@ -165,13 +170,15 @@ func (r *Store) GetProfitTrends(startAt, endAt time.Time) ([]dashboard.ProfitTre
|
||||
orderDayExpr := dateGroupExpr(r.db, "orders.created_at", startAt.Location(), startAt)
|
||||
|
||||
rows := make([]dashboard.ProfitTrendRow, 0)
|
||||
timeWhere, timeArgs := timeRangeQuery(r.db, "orders.created_at", startAt, endAt)
|
||||
args := append(timeArgs, profitOrderStatuses())
|
||||
if err := r.db.Model(&orderdomain.OrderItem{}).Select(fmt.Sprintf(`
|
||||
%s as day,
|
||||
COALESCE(SUM(order_items.total_price - order_items.coupon_discount), 0) as revenue,
|
||||
COALESCE(SUM(order_items.cost_price * order_items.quantity), 0) as cost
|
||||
`, orderDayExpr)).
|
||||
Joins("JOIN orders ON orders.id = order_items.order_id").
|
||||
Where("order_items.deleted_at IS NULL AND orders.deleted_at IS NULL AND orders.created_at >= ? AND orders.created_at < ? AND orders.status IN ?", startAt, endAt, profitOrderStatuses()).
|
||||
Where("order_items.deleted_at IS NULL AND orders.deleted_at IS NULL AND "+timeWhere+" AND orders.status IN ?", args...).
|
||||
Group(orderDayExpr).
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
@@ -188,12 +195,14 @@ func (r *Store) GetProfitTrends(startAt, endAt time.Time) ([]dashboard.ProfitTre
|
||||
}
|
||||
paymentFeeRows := make([]paymentFeeTrendRow, 0)
|
||||
paymentFeeDayExpr := dateGroupExpr(r.db, "payments.created_at", startAt.Location(), startAt)
|
||||
paymentTimeWhere, paymentTimeArgs := timeRangeQuery(r.db, "created_at", startAt, endAt)
|
||||
paymentArgs := append(paymentTimeArgs, constants.PaymentStatusSuccess, constants.PaymentProviderWallet, constants.PaymentFeePolicyMerchantAbsorbed)
|
||||
if err := r.db.Model(&paymentdomain.Payment{}).
|
||||
Select(fmt.Sprintf(`
|
||||
%s as day,
|
||||
COALESCE(SUM(fee_amount), 0) as payment_fee
|
||||
`, paymentFeeDayExpr)).
|
||||
Where("deleted_at IS NULL AND created_at >= ? AND created_at < ? AND status = ? AND provider_type <> ? AND fee_policy = ?", startAt, endAt, constants.PaymentStatusSuccess, constants.PaymentProviderWallet, constants.PaymentFeePolicyMerchantAbsorbed).
|
||||
Where("deleted_at IS NULL AND "+paymentTimeWhere+" AND status = ? AND provider_type <> ? AND fee_policy = ?", paymentArgs...).
|
||||
Group(paymentFeeDayExpr).
|
||||
Scan(&paymentFeeRows).Error; err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -70,3 +70,15 @@ func quotedStatusList(statuses []string) string {
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// timeRangeQuery 返回时间范围查询的 WHERE 条件和参数
|
||||
// 在 SQLite 下使用 datetime() 函数确保格式一致,避免字符串比较错误
|
||||
func timeRangeQuery(db *gorm.DB, column string, startAt, endAt time.Time) (string, []interface{}) {
|
||||
switch dialectName(db) {
|
||||
case "postgres", "postgresql":
|
||||
return fmt.Sprintf("%s >= ? AND %s < ?", column, column), []interface{}{startAt, endAt}
|
||||
default:
|
||||
// SQLite: 使用 datetime() 函数确保时间比较正确
|
||||
return fmt.Sprintf("datetime(%s) >= datetime(?) AND datetime(%s) < datetime(?)", column, column), []interface{}{startAt.Format(time.RFC3339Nano), endAt.Format(time.RFC3339Nano)}
|
||||
}
|
||||
}
|
||||
@@ -32,9 +32,11 @@ func paidOrderStatuses() []string {
|
||||
}
|
||||
|
||||
func onlinePaymentBase(db *gorm.DB, startAt, endAt time.Time) *gorm.DB {
|
||||
timeWhere, timeArgs := timeRangeQuery(db, "payments.created_at", startAt, endAt)
|
||||
args := append(timeArgs, constants.PaymentProviderWallet)
|
||||
return db.Model(&paymentdomain.Payment{}).
|
||||
Where("payments.deleted_at IS NULL").
|
||||
Where("payments.created_at >= ? AND payments.created_at < ? AND payments.provider_type <> ?", startAt, endAt, constants.PaymentProviderWallet)
|
||||
Where(timeWhere+" AND payments.provider_type <> ?", args...)
|
||||
}
|
||||
|
||||
var _ dashboard.Repository = (*Store)(nil)
|
||||
@@ -22,9 +22,10 @@ func (r *Store) GetOrderTrends(startAt, endAt time.Time) ([]dashboard.OrderTrend
|
||||
COALESCE(SUM(CASE WHEN status IN (%s) THEN 1 ELSE 0 END), 0) as orders_paid
|
||||
`, dayExpr, paidIn)
|
||||
|
||||
timeWhere, timeArgs := timeRangeQuery(r.db, "created_at", startAt, endAt)
|
||||
if err := r.db.Model(&orderdomain.Order{}).
|
||||
Select(selectSQL).
|
||||
Where("deleted_at IS NULL AND parent_id IS NULL AND created_at >= ? AND created_at < ?", startAt, endAt).
|
||||
Where("deleted_at IS NULL AND parent_id IS NULL AND "+timeWhere, timeArgs...).
|
||||
Group(dayExpr).
|
||||
Order("day ASC").
|
||||
Scan(&rows).Error; err != nil {
|
||||
|
||||
@@ -53,6 +53,7 @@ func setupPostgresIntegrationDB(t *testing.T) *gorm.DB {
|
||||
&paymentdomain.Payment{},
|
||||
&orderdomain.Order{},
|
||||
&contentdomain.PostProduct{},
|
||||
&productdomain.ProductSKU{},
|
||||
&productdomain.Product{},
|
||||
&categorydomain.Category{},
|
||||
&contentdomain.Banner{},
|
||||
@@ -60,9 +61,11 @@ func setupPostgresIntegrationDB(t *testing.T) *gorm.DB {
|
||||
}
|
||||
_ = db.Migrator().DropTable(cleanupModels...)
|
||||
|
||||
// product_skus 必须建表:商品搜索条件里含有对 product_skus 的 EXISTS 子查询
|
||||
if err := db.AutoMigrate(
|
||||
&categorydomain.Category{},
|
||||
&productdomain.Product{},
|
||||
&productdomain.ProductSKU{},
|
||||
&contentdomain.Post{},
|
||||
&contentdomain.PostProduct{},
|
||||
&contentdomain.Banner{},
|
||||
@@ -284,6 +287,101 @@ func TestPostgresContentGormStoresPreserveQuerySemantics(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 后台商品列表的上架状态筛选在 PostgreSQL 上需与 SQLite 语义一致:
|
||||
// IsActive 三态过滤必须与 search(PostgreSQL 走 ILIKE + jsonb ->>)、
|
||||
// wholesale(PostgreSQL 走 jsonb_array_length)按 AND 正确组合。
|
||||
func TestPostgresProductListActiveStateFilter(t *testing.T) {
|
||||
db := setupPostgresIntegrationDB(t)
|
||||
|
||||
category := &categorydomain.Category{
|
||||
Slug: "pg-active-state-category",
|
||||
NameJSON: jsonmap.JSON{"zh-CN": "上架状态分类"},
|
||||
}
|
||||
if err := db.Create(category).Error; err != nil {
|
||||
t.Fatalf("create category failed: %v", err)
|
||||
}
|
||||
|
||||
productRepo := productgormstore.NewProductStore(db)
|
||||
|
||||
seed := func(slug string, isActive bool, withWholesale bool) *productdomain.Product {
|
||||
product := &productdomain.Product{
|
||||
CategoryID: category.ID,
|
||||
Slug: slug,
|
||||
TitleJSON: jsonmap.JSON{"zh-CN": "上架状态商品"},
|
||||
PriceAmount: money.FromDecimal(decimal.NewFromInt(50)),
|
||||
PurchaseType: constants.ProductPurchaseMember,
|
||||
FulfillmentType: constants.FulfillmentTypeManual,
|
||||
ManualStockTotal: 5,
|
||||
IsActive: isActive,
|
||||
}
|
||||
if withWholesale {
|
||||
product.WholesalePrices = productdomain.WholesalePriceTiers{
|
||||
{MinQuantity: 5, UnitPrice: money.FromDecimal(decimal.NewFromInt(40))},
|
||||
}
|
||||
}
|
||||
if err := productRepo.Create(product); err != nil {
|
||||
t.Fatalf("create postgres product %q failed: %v", slug, err)
|
||||
}
|
||||
// is_active 带 default:false,需确认下架商品在 PostgreSQL 上确实落库为 false
|
||||
var stored productdomain.Product
|
||||
if err := db.First(&stored, product.ID).Error; err != nil {
|
||||
t.Fatalf("reload postgres product %q failed: %v", slug, err)
|
||||
}
|
||||
if stored.IsActive != isActive {
|
||||
t.Fatalf("postgres product %q stored is_active=%t want %t", slug, stored.IsActive, isActive)
|
||||
}
|
||||
return product
|
||||
}
|
||||
|
||||
active := seed("pg-state-active", true, true)
|
||||
inactive := seed("pg-state-inactive", false, true)
|
||||
inactiveWithoutWholesale := seed("pg-state-inactive-plain", false, false)
|
||||
|
||||
listSlugs := func(filter productcontract.ListFilter) map[string]bool {
|
||||
t.Helper()
|
||||
filter.Page = 1
|
||||
filter.PageSize = 50
|
||||
rows, _, err := productRepo.List(filter)
|
||||
if err != nil {
|
||||
t.Fatalf("postgres product list failed: %v", err)
|
||||
}
|
||||
got := make(map[string]bool, len(rows))
|
||||
for _, row := range rows {
|
||||
got[row.Slug] = true
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
onlyActive := true
|
||||
onlyInactive := false
|
||||
withWholesale := true
|
||||
|
||||
got := listSlugs(productcontract.ListFilter{IsActive: &onlyActive})
|
||||
if !got[active.Slug] || got[inactive.Slug] {
|
||||
t.Fatalf("postgres IsActive=true should return only active rows, got %+v", got)
|
||||
}
|
||||
|
||||
got = listSlugs(productcontract.ListFilter{IsActive: &onlyInactive})
|
||||
if !got[inactive.Slug] || !got[inactiveWithoutWholesale.Slug] || got[active.Slug] {
|
||||
t.Fatalf("postgres IsActive=false should return only inactive rows, got %+v", got)
|
||||
}
|
||||
|
||||
got = listSlugs(productcontract.ListFilter{IsActive: &onlyInactive, Search: "上架状态"})
|
||||
if !got[inactive.Slug] || got[active.Slug] {
|
||||
t.Fatalf("postgres IsActive=false + localized search mismatch, got %+v", got)
|
||||
}
|
||||
|
||||
got = listSlugs(productcontract.ListFilter{IsActive: &onlyInactive, HasWholesalePrices: &withWholesale})
|
||||
if !got[inactive.Slug] || got[inactiveWithoutWholesale.Slug] || got[active.Slug] {
|
||||
t.Fatalf("postgres IsActive=false + wholesale mismatch, got %+v", got)
|
||||
}
|
||||
|
||||
got = listSlugs(productcontract.ListFilter{IsActive: &onlyInactive, Search: "绝不匹配的关键词"})
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("postgres IsActive=false + non-matching search should be empty, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresDashboardQueries(t *testing.T) {
|
||||
db := setupPostgresIntegrationDB(t)
|
||||
repo := dashboardgormstore.New(db)
|
||||
|
||||
Reference in new issue
Block a user