Merge remote-tracking branch 'github/main'

This commit is contained in:
dujiao-next committed 2026-09-15 22:19:04 +08:00
commit 47eda40374
22 files changed
+787 -394

No files matched your search

+12
View File
@@ -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">
+1 -1
View File
@@ -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>
+151 -151
View File
@@ -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>
+161 -161
View File
@@ -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)