feat: add admin affiliate record pages
This commit is contained in:
@@ -23,6 +23,71 @@ export interface ListAffiliateUsersParams {
|
||||
search?: string
|
||||
}
|
||||
|
||||
export interface ListAffiliateRecordsParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
search?: string
|
||||
start_at?: string
|
||||
end_at?: string
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
timezone?: string
|
||||
}
|
||||
|
||||
export interface AffiliateInviteRecord {
|
||||
inviter_id: number
|
||||
inviter_email: string
|
||||
inviter_username: string
|
||||
invitee_id: number
|
||||
invitee_email: string
|
||||
invitee_username: string
|
||||
aff_code: string
|
||||
total_rebate: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AffiliateRebateRecord {
|
||||
order_id: number
|
||||
out_trade_no: string
|
||||
inviter_id: number
|
||||
inviter_email: string
|
||||
inviter_username: string
|
||||
invitee_id: number
|
||||
invitee_email: string
|
||||
invitee_username: string
|
||||
order_amount: number
|
||||
pay_amount: number
|
||||
rebate_amount: number
|
||||
payment_type: string
|
||||
order_status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AffiliateTransferRecord {
|
||||
ledger_id: number
|
||||
user_id: number
|
||||
user_email: string
|
||||
username: string
|
||||
amount: number
|
||||
current_balance: number
|
||||
remaining_quota: number
|
||||
frozen_quota: number
|
||||
history_quota: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AffiliateUserOverview {
|
||||
user_id: number
|
||||
email: string
|
||||
username: string
|
||||
aff_code: string
|
||||
rebate_rate_percent: number
|
||||
invited_count: number
|
||||
rebated_invitee_count: number
|
||||
available_quota: number
|
||||
history_quota: number
|
||||
}
|
||||
|
||||
export interface UpdateAffiliateUserRequest {
|
||||
aff_code?: string
|
||||
aff_rebate_rate_percent?: number | null
|
||||
@@ -97,12 +162,68 @@ export async function batchSetRate(
|
||||
return data
|
||||
}
|
||||
|
||||
function recordParams(params: ListAffiliateRecordsParams = {}) {
|
||||
return {
|
||||
page: params.page ?? 1,
|
||||
page_size: params.page_size ?? 20,
|
||||
search: params.search ?? '',
|
||||
start_at: params.start_at || undefined,
|
||||
end_at: params.end_at || undefined,
|
||||
sort_by: params.sort_by || undefined,
|
||||
sort_order: params.sort_order || undefined,
|
||||
timezone: params.timezone || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export async function listInviteRecords(
|
||||
params: ListAffiliateRecordsParams = {},
|
||||
): Promise<PaginatedResponse<AffiliateInviteRecord>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<AffiliateInviteRecord>>(
|
||||
'/admin/affiliates/invites',
|
||||
{ params: recordParams(params) },
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function listRebateRecords(
|
||||
params: ListAffiliateRecordsParams = {},
|
||||
): Promise<PaginatedResponse<AffiliateRebateRecord>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<AffiliateRebateRecord>>(
|
||||
'/admin/affiliates/rebates',
|
||||
{ params: recordParams(params) },
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function listTransferRecords(
|
||||
params: ListAffiliateRecordsParams = {},
|
||||
): Promise<PaginatedResponse<AffiliateTransferRecord>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<AffiliateTransferRecord>>(
|
||||
'/admin/affiliates/transfers',
|
||||
{ params: recordParams(params) },
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getUserOverview(
|
||||
userId: number,
|
||||
): Promise<AffiliateUserOverview> {
|
||||
const { data } = await apiClient.get<AffiliateUserOverview>(
|
||||
`/admin/affiliates/users/${userId}/overview`,
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export const affiliatesAPI = {
|
||||
listUsers,
|
||||
lookupUsers,
|
||||
updateUserSettings,
|
||||
clearUserSettings,
|
||||
batchSetRate,
|
||||
listInviteRecords,
|
||||
listRebateRecords,
|
||||
listTransferRecords,
|
||||
getUserOverview,
|
||||
}
|
||||
|
||||
export default affiliatesAPI
|
||||
|
||||
@@ -721,6 +721,19 @@ const adminNavItems = computed((): NavItem[] => {
|
||||
{ path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon },
|
||||
{ path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon, hideInSimpleMode: true },
|
||||
{ path: '/admin/promo-codes', label: t('nav.promoCodes'), icon: GiftIcon, hideInSimpleMode: true },
|
||||
{
|
||||
path: '/admin/affiliates',
|
||||
label: t('nav.affiliateManagement'),
|
||||
icon: UsersIcon,
|
||||
hideInSimpleMode: true,
|
||||
expandOnly: true,
|
||||
featureFlag: flagAffiliate,
|
||||
children: [
|
||||
{ path: '/admin/affiliates/invites', label: t('nav.affiliateInviteRecords'), icon: UsersIcon },
|
||||
{ path: '/admin/affiliates/rebates', label: t('nav.affiliateRebateRecords'), icon: OrderIcon },
|
||||
{ path: '/admin/affiliates/transfers', label: t('nav.affiliateTransferRecords'), icon: CreditCardIcon },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/admin/orders',
|
||||
label: t('nav.orderManagement'),
|
||||
|
||||
@@ -347,6 +347,10 @@ export default {
|
||||
usage: 'Usage',
|
||||
redeem: 'Redeem',
|
||||
affiliate: 'Affiliate Rebates',
|
||||
affiliateManagement: 'Affiliate Rebates',
|
||||
affiliateInviteRecords: 'Invite Records',
|
||||
affiliateRebateRecords: 'Rebate Records',
|
||||
affiliateTransferRecords: 'Transfer Records',
|
||||
profile: 'Profile',
|
||||
users: 'Users',
|
||||
groups: 'Groups',
|
||||
@@ -1635,6 +1639,49 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
affiliates: {
|
||||
invitesDescription: 'View site-wide inviter and invitee relationships',
|
||||
rebatesDescription: 'View recharge orders that generated affiliate rebates',
|
||||
transfersDescription: 'View affiliate quota transfers into account balance',
|
||||
errors: {
|
||||
loadFailed: 'Failed to load affiliate records'
|
||||
},
|
||||
records: {
|
||||
search: 'Search',
|
||||
searchPlaceholder: 'Email, username, user ID, or order number',
|
||||
startAt: 'Start date',
|
||||
endAt: 'End date',
|
||||
inviter: 'Inviter',
|
||||
invitee: 'Invitee',
|
||||
user: 'User',
|
||||
affCode: 'Invite Code',
|
||||
order: 'Order',
|
||||
totalRebate: 'Total Rebate',
|
||||
orderAmount: 'Top-up Amount',
|
||||
payAmount: 'Paid Amount',
|
||||
rebateAmount: 'Rebate Amount',
|
||||
paymentType: 'Payment Method',
|
||||
orderStatus: 'Order Status',
|
||||
transferAmount: 'Transfer Amount',
|
||||
currentBalance: 'Current Balance',
|
||||
remainingQuota: 'Remaining Quota',
|
||||
frozenQuota: 'Frozen Rebate',
|
||||
historyQuota: 'Historical Rebate',
|
||||
invitedAt: 'Invited At',
|
||||
rebatedAt: 'Rebated At',
|
||||
transferredAt: 'Transferred At'
|
||||
},
|
||||
overview: {
|
||||
title: 'Affiliate User Overview',
|
||||
affCode: 'Invite Code',
|
||||
rebateRate: 'Rebate Rate',
|
||||
invitedCount: 'Invited Users',
|
||||
rebatedInviteeCount: 'Rebated Invitees',
|
||||
availableQuota: 'Available Quota',
|
||||
historyQuota: 'Historical Rebate'
|
||||
}
|
||||
},
|
||||
|
||||
// Users
|
||||
users: {
|
||||
title: 'User Management',
|
||||
|
||||
@@ -347,6 +347,10 @@ export default {
|
||||
usage: '使用记录',
|
||||
redeem: '兑换',
|
||||
affiliate: '邀请返利',
|
||||
affiliateManagement: '邀请返利',
|
||||
affiliateInviteRecords: '邀请记录',
|
||||
affiliateRebateRecords: '返利记录',
|
||||
affiliateTransferRecords: '提取记录',
|
||||
profile: '个人资料',
|
||||
users: '用户管理',
|
||||
groups: '分组管理',
|
||||
@@ -1656,6 +1660,49 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
affiliates: {
|
||||
invitesDescription: '查看全站邀请关系和被邀请用户累计返利',
|
||||
rebatesDescription: '查看每一笔产生返利的充值订单',
|
||||
transfersDescription: '查看返利额度转入账户余额的提取流水',
|
||||
errors: {
|
||||
loadFailed: '加载邀请返利记录失败'
|
||||
},
|
||||
records: {
|
||||
search: '搜索',
|
||||
searchPlaceholder: '邮箱、用户名、用户 ID、订单号',
|
||||
startAt: '开始日期',
|
||||
endAt: '结束日期',
|
||||
inviter: '邀请人',
|
||||
invitee: '被邀请人',
|
||||
user: '用户',
|
||||
affCode: '邀请码',
|
||||
order: '订单',
|
||||
totalRebate: '累计返利',
|
||||
orderAmount: '充值金额',
|
||||
payAmount: '支付金额',
|
||||
rebateAmount: '返利金额',
|
||||
paymentType: '支付方式',
|
||||
orderStatus: '订单状态',
|
||||
transferAmount: '提取金额',
|
||||
currentBalance: '当前余额',
|
||||
remainingQuota: '剩余可提取',
|
||||
frozenQuota: '冻结返利',
|
||||
historyQuota: '历史返利',
|
||||
invitedAt: '邀请时间',
|
||||
rebatedAt: '返利时间',
|
||||
transferredAt: '提取时间'
|
||||
},
|
||||
overview: {
|
||||
title: '用户返利概览',
|
||||
affCode: '邀请码',
|
||||
rebateRate: '返利比例',
|
||||
invitedCount: '邀请人数',
|
||||
rebatedInviteeCount: '已产生返利人数',
|
||||
availableQuota: '可提余额',
|
||||
historyQuota: '历史返利'
|
||||
}
|
||||
},
|
||||
|
||||
// Users Management
|
||||
users: {
|
||||
title: '用户管理',
|
||||
|
||||
@@ -517,6 +517,46 @@ const routes: RouteRecordRaw[] = [
|
||||
descriptionKey: 'admin.usage.description'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/affiliates',
|
||||
redirect: '/admin/affiliates/invites'
|
||||
},
|
||||
{
|
||||
path: '/admin/affiliates/invites',
|
||||
name: 'AdminAffiliateInvites',
|
||||
component: () => import('@/views/admin/affiliates/AdminAffiliateInvitesView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: true,
|
||||
title: 'Affiliate Invite Records',
|
||||
titleKey: 'nav.affiliateInviteRecords',
|
||||
descriptionKey: 'admin.affiliates.invitesDescription'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/affiliates/rebates',
|
||||
name: 'AdminAffiliateRebates',
|
||||
component: () => import('@/views/admin/affiliates/AdminAffiliateRebatesView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: true,
|
||||
title: 'Affiliate Rebate Records',
|
||||
titleKey: 'nav.affiliateRebateRecords',
|
||||
descriptionKey: 'admin.affiliates.rebatesDescription'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/affiliates/transfers',
|
||||
name: 'AdminAffiliateTransfers',
|
||||
component: () => import('@/views/admin/affiliates/AdminAffiliateTransfersView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: true,
|
||||
title: 'Affiliate Transfer Records',
|
||||
titleKey: 'nav.affiliateTransferRecords',
|
||||
descriptionKey: 'admin.affiliates.transfersDescription'
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// ==================== Payment Admin Routes ====================
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<AdminAffiliateRecordsTable type="invites" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AdminAffiliateRecordsTable from './AdminAffiliateRecordsTable.vue'
|
||||
</script>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<AdminAffiliateRecordsTable type="rebates" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AdminAffiliateRecordsTable from './AdminAffiliateRecordsTable.vue'
|
||||
</script>
|
||||
@@ -0,0 +1,386 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<TablePageLayout>
|
||||
<template #filters>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="relative w-full md:w-80">
|
||||
<Icon name="search" size="md" class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input v-model="filters.search" type="text" class="input pl-10" :placeholder="t('admin.affiliates.records.searchPlaceholder')" @input="debounceLoad" />
|
||||
</div>
|
||||
<input v-model="filters.start_at" type="date" class="input w-full sm:w-44" :title="t('admin.affiliates.records.startAt')" @change="reloadFromFirstPage" />
|
||||
<input v-model="filters.end_at" type="date" class="input w-full sm:w-44" :title="t('admin.affiliates.records.endAt')" @change="reloadFromFirstPage" />
|
||||
<button class="btn btn-secondary px-2 md:px-3" :disabled="loading" :title="t('common.refresh')" @click="loadRecords">
|
||||
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #table>
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="records"
|
||||
:loading="loading"
|
||||
:server-side-sort="true"
|
||||
default-sort-key="created_at"
|
||||
default-sort-order="desc"
|
||||
:sort-storage-key="sortStorageKey"
|
||||
@sort="handleSort"
|
||||
>
|
||||
<template #cell-inviter="{ row }">
|
||||
<UserCell
|
||||
:id="row.inviter_id"
|
||||
:email="row.inviter_email"
|
||||
:username="row.inviter_username"
|
||||
:clickable="props.type === 'invites'"
|
||||
@open="openUserOverview"
|
||||
/>
|
||||
</template>
|
||||
<template #cell-invitee="{ row }">
|
||||
<UserCell
|
||||
:id="row.invitee_id"
|
||||
:email="row.invitee_email"
|
||||
:username="row.invitee_username"
|
||||
:clickable="props.type === 'invites'"
|
||||
@open="openUserOverview"
|
||||
/>
|
||||
</template>
|
||||
<template #cell-user="{ row }">
|
||||
<UserCell :id="row.user_id" :email="row.user_email" :username="row.username" />
|
||||
</template>
|
||||
<template #cell-aff_code="{ row }">
|
||||
<span class="font-mono text-sm text-gray-700 dark:text-gray-300">{{ row.aff_code || '-' }}</span>
|
||||
</template>
|
||||
<template #cell-order="{ row }">
|
||||
<div class="space-y-0.5">
|
||||
<div class="font-mono text-sm text-gray-900 dark:text-white">#{{ row.order_id }}</div>
|
||||
<div class="max-w-56 truncate text-sm text-gray-500 dark:text-dark-400">{{ row.out_trade_no }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell-payment_type="{ row }">
|
||||
{{ t('payment.methods.' + row.payment_type, row.payment_type || '-') }}
|
||||
</template>
|
||||
<template #cell-order_status="{ row }">
|
||||
<OrderStatusBadge :status="row.order_status" />
|
||||
</template>
|
||||
<template #cell-total_rebate="{ row }">
|
||||
<AmountText :value="row.total_rebate" />
|
||||
</template>
|
||||
<template #cell-order_amount="{ row }">
|
||||
<AmountText :value="row.order_amount" />
|
||||
</template>
|
||||
<template #cell-pay_amount="{ row }">
|
||||
<span class="text-sm text-gray-900 dark:text-white">¥{{ formatAmount(row.pay_amount) }}</span>
|
||||
</template>
|
||||
<template #cell-rebate_amount="{ row }">
|
||||
<AmountText :value="row.rebate_amount" strong />
|
||||
</template>
|
||||
<template #cell-amount="{ row }">
|
||||
<AmountText :value="row.amount" strong />
|
||||
</template>
|
||||
<template #cell-current_balance="{ row }">
|
||||
<AmountText :value="row.current_balance" />
|
||||
</template>
|
||||
<template #cell-remaining_quota="{ row }">
|
||||
<AmountText :value="row.remaining_quota" />
|
||||
</template>
|
||||
<template #cell-frozen_quota="{ row }">
|
||||
<AmountText :value="row.frozen_quota" />
|
||||
</template>
|
||||
<template #cell-history_quota="{ row }">
|
||||
<AmountText :value="row.history_quota" />
|
||||
</template>
|
||||
<template #cell-created_at="{ row }">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ formatDateTime(row.created_at) }}</span>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
|
||||
<template #pagination>
|
||||
<Pagination
|
||||
v-if="pagination.total > 0"
|
||||
:page="pagination.page"
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.page_size"
|
||||
@update:page="handlePageChange"
|
||||
@update:pageSize="handlePageSizeChange"
|
||||
/>
|
||||
</template>
|
||||
</TablePageLayout>
|
||||
|
||||
<BaseDialog
|
||||
:show="overviewDialog"
|
||||
:title="t('admin.affiliates.overview.title')"
|
||||
width="normal"
|
||||
@close="overviewDialog = false"
|
||||
>
|
||||
<div v-if="overviewLoading" class="flex justify-center py-8">
|
||||
<div class="h-6 w-6 animate-spin rounded-full border-2 border-primary-500 border-t-transparent"></div>
|
||||
</div>
|
||||
<div v-else-if="selectedOverview" class="space-y-4">
|
||||
<div class="rounded-lg border border-gray-100 bg-gray-50 p-4 dark:border-dark-700 dark:bg-dark-800">
|
||||
<div class="font-mono text-sm text-gray-900 dark:text-white">#{{ selectedOverview.user_id }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">{{ selectedOverview.email || '-' }}</div>
|
||||
<div class="mt-0.5 text-sm text-gray-500 dark:text-dark-400">{{ selectedOverview.username || '-' }}</div>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<OverviewStat :label="t('admin.affiliates.overview.affCode')" :value="selectedOverview.aff_code || '-'" mono />
|
||||
<OverviewStat :label="t('admin.affiliates.overview.rebateRate')" :value="formatPercent(selectedOverview.rebate_rate_percent)" />
|
||||
<OverviewStat :label="t('admin.affiliates.overview.invitedCount')" :value="String(selectedOverview.invited_count)" />
|
||||
<OverviewStat :label="t('admin.affiliates.overview.rebatedInviteeCount')" :value="String(selectedOverview.rebated_invitee_count)" />
|
||||
<OverviewStat :label="t('admin.affiliates.overview.availableQuota')" :value="'$' + formatAmount(selectedOverview.available_quota)" />
|
||||
<OverviewStat :label="t('admin.affiliates.overview.historyQuota')" :value="'$' + formatAmount(selectedOverview.history_quota)" />
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineComponent, h, onMounted, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import OrderStatusBadge from '@/components/payment/OrderStatusBadge.vue'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { affiliatesAPI, type AffiliateInviteRecord, type AffiliateRebateRecord, type AffiliateTransferRecord, type AffiliateUserOverview, type ListAffiliateRecordsParams } from '@/api/admin/affiliates'
|
||||
import type { PaginatedResponse } from '@/types'
|
||||
import { extractI18nErrorMessage } from '@/utils/apiError'
|
||||
import { formatDateTime as formatDisplayDateTime } from '@/utils/format'
|
||||
|
||||
type RecordType = 'invites' | 'rebates' | 'transfers'
|
||||
type AffiliateRecord = AffiliateInviteRecord | AffiliateRebateRecord | AffiliateTransferRecord
|
||||
|
||||
const props = defineProps<{
|
||||
type: RecordType
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const loading = ref(false)
|
||||
const records = ref<AffiliateRecord[]>([])
|
||||
const filters = reactive({ search: '', start_at: '', end_at: '' })
|
||||
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
|
||||
const overviewDialog = ref(false)
|
||||
const overviewLoading = ref(false)
|
||||
const selectedOverview = ref<AffiliateUserOverview | null>(null)
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const columns = computed<Column[]>(() => {
|
||||
if (props.type === 'invites') {
|
||||
return [
|
||||
{ key: 'inviter', label: t('admin.affiliates.records.inviter'), sortable: true },
|
||||
{ key: 'invitee', label: t('admin.affiliates.records.invitee'), sortable: true },
|
||||
{ key: 'aff_code', label: t('admin.affiliates.records.affCode'), sortable: true },
|
||||
{ key: 'total_rebate', label: t('admin.affiliates.records.totalRebate'), sortable: true },
|
||||
{ key: 'created_at', label: t('admin.affiliates.records.invitedAt'), sortable: true },
|
||||
]
|
||||
}
|
||||
if (props.type === 'rebates') {
|
||||
return [
|
||||
{ key: 'order', label: t('admin.affiliates.records.order'), sortable: true },
|
||||
{ key: 'inviter', label: t('admin.affiliates.records.inviter'), sortable: true },
|
||||
{ key: 'invitee', label: t('admin.affiliates.records.invitee'), sortable: true },
|
||||
{ key: 'order_amount', label: t('admin.affiliates.records.orderAmount'), sortable: true },
|
||||
{ key: 'pay_amount', label: t('admin.affiliates.records.payAmount'), sortable: true },
|
||||
{ key: 'rebate_amount', label: t('admin.affiliates.records.rebateAmount') },
|
||||
{ key: 'payment_type', label: t('admin.affiliates.records.paymentType'), sortable: true },
|
||||
{ key: 'order_status', label: t('admin.affiliates.records.orderStatus'), sortable: true },
|
||||
{ key: 'created_at', label: t('admin.affiliates.records.rebatedAt'), sortable: true },
|
||||
]
|
||||
}
|
||||
return [
|
||||
{ key: 'user', label: t('admin.affiliates.records.user'), sortable: true },
|
||||
{ key: 'amount', label: t('admin.affiliates.records.transferAmount'), sortable: true },
|
||||
{ key: 'current_balance', label: t('admin.affiliates.records.currentBalance'), sortable: true },
|
||||
{ key: 'remaining_quota', label: t('admin.affiliates.records.remainingQuota'), sortable: true },
|
||||
{ key: 'frozen_quota', label: t('admin.affiliates.records.frozenQuota'), sortable: true },
|
||||
{ key: 'history_quota', label: t('admin.affiliates.records.historyQuota'), sortable: true },
|
||||
{ key: 'created_at', label: t('admin.affiliates.records.transferredAt'), sortable: true },
|
||||
]
|
||||
})
|
||||
|
||||
const sortStorageKey = computed(() => `admin-affiliate-${props.type}-table-sort`)
|
||||
|
||||
function loadInitialSortState(): { sort_by: string; sort_order: 'asc' | 'desc' } {
|
||||
const fallback = { sort_by: 'created_at', sort_order: 'desc' as 'asc' | 'desc' }
|
||||
try {
|
||||
const raw = localStorage.getItem(sortStorageKey.value)
|
||||
if (!raw) return fallback
|
||||
const parsed = JSON.parse(raw) as { key?: string; order?: string }
|
||||
const key = typeof parsed.key === 'string' ? parsed.key : ''
|
||||
if (!columns.value.some((column) => column.key === key && column.sortable)) return fallback
|
||||
return {
|
||||
sort_by: key,
|
||||
sort_order: parsed.order === 'asc' ? 'asc' : 'desc',
|
||||
}
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
const sortState = reactive(loadInitialSortState())
|
||||
|
||||
function userTimezone(): string {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
} catch {
|
||||
return 'UTC'
|
||||
}
|
||||
}
|
||||
|
||||
function buildParams(): ListAffiliateRecordsParams {
|
||||
return {
|
||||
page: pagination.page,
|
||||
page_size: pagination.page_size,
|
||||
search: filters.search.trim() || undefined,
|
||||
start_at: filters.start_at || undefined,
|
||||
end_at: filters.end_at || undefined,
|
||||
sort_by: sortState.sort_by,
|
||||
sort_order: sortState.sort_order,
|
||||
timezone: userTimezone(),
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRecords(params: ListAffiliateRecordsParams): Promise<PaginatedResponse<AffiliateRecord>> {
|
||||
if (props.type === 'invites') {
|
||||
return affiliatesAPI.listInviteRecords(params)
|
||||
}
|
||||
if (props.type === 'rebates') {
|
||||
return affiliatesAPI.listRebateRecords(params)
|
||||
}
|
||||
return affiliatesAPI.listTransferRecords(params)
|
||||
}
|
||||
|
||||
async function loadRecords() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchRecords(buildParams())
|
||||
records.value = res.items || []
|
||||
pagination.total = res.total || 0
|
||||
} catch (error) {
|
||||
appStore.showError(extractI18nErrorMessage(error, t, 'admin.affiliates.errors', t('common.error')))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function debounceLoad() {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => reloadFromFirstPage(), 300)
|
||||
}
|
||||
|
||||
function reloadFromFirstPage() {
|
||||
pagination.page = 1
|
||||
void loadRecords()
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
pagination.page = page
|
||||
void loadRecords()
|
||||
}
|
||||
|
||||
function handlePageSizeChange(size: number) {
|
||||
pagination.page_size = size
|
||||
pagination.page = 1
|
||||
void loadRecords()
|
||||
}
|
||||
|
||||
function handleSort(key: string, order: 'asc' | 'desc') {
|
||||
sortState.sort_by = key
|
||||
sortState.sort_order = order
|
||||
pagination.page = 1
|
||||
void loadRecords()
|
||||
}
|
||||
|
||||
function formatAmount(value: number | null | undefined): string {
|
||||
return Number(value || 0).toFixed(2)
|
||||
}
|
||||
|
||||
function formatPercent(value: number | null | undefined): string {
|
||||
const rounded = Math.round(Number(value || 0) * 100) / 100
|
||||
return `${Number.isInteger(rounded) ? rounded.toString() : rounded.toString()}%`
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null | undefined): string {
|
||||
return value ? formatDisplayDateTime(value) : '-'
|
||||
}
|
||||
|
||||
async function openUserOverview(userId: number) {
|
||||
if (!userId) return
|
||||
overviewDialog.value = true
|
||||
overviewLoading.value = true
|
||||
selectedOverview.value = null
|
||||
try {
|
||||
selectedOverview.value = await affiliatesAPI.getUserOverview(userId)
|
||||
} catch (error) {
|
||||
overviewDialog.value = false
|
||||
appStore.showError(extractI18nErrorMessage(error, t, 'admin.affiliates.errors', t('common.error')))
|
||||
} finally {
|
||||
overviewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const UserCell = defineComponent({
|
||||
props: {
|
||||
id: { type: Number, required: true },
|
||||
email: { type: String, default: '' },
|
||||
username: { type: String, default: '' },
|
||||
clickable: { type: Boolean, default: false },
|
||||
},
|
||||
emits: ['open'],
|
||||
setup(cellProps, { emit }) {
|
||||
return () => h('div', { class: 'space-y-0.5' }, [
|
||||
h('div', { class: 'font-mono text-sm text-gray-900 dark:text-white' }, `#${cellProps.id}`),
|
||||
h(cellProps.clickable ? 'button' : 'div', {
|
||||
class: cellProps.clickable
|
||||
? 'max-w-56 truncate text-left text-sm font-medium text-primary-600 hover:text-primary-700 hover:underline dark:text-primary-400 dark:hover:text-primary-300'
|
||||
: 'max-w-56 truncate text-sm text-gray-700 dark:text-gray-300',
|
||||
type: cellProps.clickable ? 'button' : undefined,
|
||||
onClick: cellProps.clickable ? () => emit('open', cellProps.id) : undefined,
|
||||
}, cellProps.email || '-'),
|
||||
h('div', { class: 'max-w-56 truncate text-sm text-gray-500 dark:text-dark-400' }, cellProps.username || '-'),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
const AmountText = defineComponent({
|
||||
props: {
|
||||
value: { type: Number, default: 0 },
|
||||
strong: { type: Boolean, default: false },
|
||||
},
|
||||
setup(amountProps) {
|
||||
return () => h('span', {
|
||||
class: amountProps.strong
|
||||
? 'text-sm font-semibold text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-sm text-gray-900 dark:text-white',
|
||||
}, `$${formatAmount(amountProps.value)}`)
|
||||
},
|
||||
})
|
||||
|
||||
const OverviewStat = defineComponent({
|
||||
props: {
|
||||
label: { type: String, required: true },
|
||||
value: { type: String, required: true },
|
||||
mono: { type: Boolean, default: false },
|
||||
},
|
||||
setup(statProps) {
|
||||
return () => h('div', { class: 'rounded-lg border border-gray-100 bg-white p-3 dark:border-dark-700 dark:bg-dark-900' }, [
|
||||
h('div', { class: 'text-sm text-gray-500 dark:text-dark-400' }, statProps.label),
|
||||
h('div', {
|
||||
class: statProps.mono
|
||||
? 'mt-1 font-mono text-base font-semibold text-gray-900 dark:text-white'
|
||||
: 'mt-1 text-base font-semibold text-gray-900 dark:text-white',
|
||||
}, statProps.value),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void loadRecords()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<AdminAffiliateRecordsTable type="transfers" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AdminAffiliateRecordsTable from './AdminAffiliateRecordsTable.vue'
|
||||
</script>
|
||||
Reference in New Issue
Block a user