feat: add coupon risk review
This commit is contained in:
@@ -109,6 +109,45 @@ export const CouponService = {
|
||||
total: data?.total ?? 0,
|
||||
items: normalizeItems(data?.items)
|
||||
};
|
||||
},
|
||||
async listCouponRisks({ page, limit, risk_type, coupon_id, tenant_id, tenant_code, tenant_name, keyword, user_id, username, status, order_status, created_at_from, created_at_to, used_at_from, used_at_to, sortField, sortOrder } = {}) {
|
||||
const iso = (d) => {
|
||||
if (!d) return undefined;
|
||||
const date = d instanceof Date ? d : new Date(d);
|
||||
if (Number.isNaN(date.getTime())) return undefined;
|
||||
return date.toISOString();
|
||||
};
|
||||
|
||||
const query = {
|
||||
page,
|
||||
limit,
|
||||
risk_type,
|
||||
coupon_id,
|
||||
tenant_id,
|
||||
tenant_code,
|
||||
tenant_name,
|
||||
keyword,
|
||||
user_id,
|
||||
username,
|
||||
status,
|
||||
order_status,
|
||||
created_at_from: iso(created_at_from),
|
||||
created_at_to: iso(created_at_to),
|
||||
used_at_from: iso(used_at_from),
|
||||
used_at_to: iso(used_at_to)
|
||||
};
|
||||
if (sortField && sortOrder) {
|
||||
if (sortOrder === 1) query.asc = sortField;
|
||||
if (sortOrder === -1) query.desc = sortField;
|
||||
}
|
||||
|
||||
const data = await requestJson('/super/v1/coupon-risks', { query });
|
||||
return {
|
||||
page: data?.page ?? page ?? 1,
|
||||
limit: data?.limit ?? limit ?? 10,
|
||||
total: data?.total ?? 0,
|
||||
items: normalizeItems(data?.items)
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -45,6 +45,28 @@ const grantCreatedAtTo = ref(null);
|
||||
const grantUsedAtFrom = ref(null);
|
||||
const grantUsedAtTo = ref(null);
|
||||
|
||||
const risks = ref([]);
|
||||
const risksLoading = ref(false);
|
||||
const risksTotal = ref(0);
|
||||
const risksPage = ref(1);
|
||||
const risksRows = ref(10);
|
||||
const riskType = ref('');
|
||||
const riskCouponID = ref(null);
|
||||
const riskTenantID = ref(null);
|
||||
const riskTenantCode = ref('');
|
||||
const riskTenantName = ref('');
|
||||
const riskKeyword = ref('');
|
||||
const riskUserID = ref(null);
|
||||
const riskUsername = ref('');
|
||||
const riskStatus = ref('');
|
||||
const riskOrderStatus = ref('');
|
||||
const riskCreatedAtFrom = ref(null);
|
||||
const riskCreatedAtTo = ref(null);
|
||||
const riskUsedAtFrom = ref(null);
|
||||
const riskUsedAtTo = ref(null);
|
||||
const riskSortField = ref('created_at');
|
||||
const riskSortOrder = ref(-1);
|
||||
|
||||
const typeOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '固定金额', value: 'fix_amount' },
|
||||
@@ -65,6 +87,27 @@ const grantStatusOptions = [
|
||||
{ label: '已过期', value: 'expired' }
|
||||
];
|
||||
|
||||
const riskStatusOptions = grantStatusOptions;
|
||||
|
||||
const riskTypeOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '已核销无订单', value: 'used_without_order' },
|
||||
{ label: '订单状态不匹配', value: 'order_status_mismatch' },
|
||||
{ label: '核销超出有效期', value: 'used_outside_window' },
|
||||
{ label: '未使用但有订单/时间', value: 'unused_has_order_or_used_at' },
|
||||
{ label: '重复领券', value: 'duplicate_grant' }
|
||||
];
|
||||
|
||||
const orderStatusOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '已创建', value: 'created' },
|
||||
{ label: '已支付', value: 'paid' },
|
||||
{ label: '退款中', value: 'refunding' },
|
||||
{ label: '已退款', value: 'refunded' },
|
||||
{ label: '已取消', value: 'canceled' },
|
||||
{ label: '失败', value: 'failed' }
|
||||
];
|
||||
|
||||
const editDialogVisible = ref(false);
|
||||
const couponSubmitting = ref(false);
|
||||
const editingCoupon = ref(null);
|
||||
@@ -135,6 +178,26 @@ function getGrantStatusSeverity(value) {
|
||||
}
|
||||
}
|
||||
|
||||
function getOrderStatusSeverity(value) {
|
||||
switch (value) {
|
||||
case 'paid':
|
||||
return 'success';
|
||||
case 'created':
|
||||
case 'refunding':
|
||||
return 'warn';
|
||||
case 'failed':
|
||||
case 'canceled':
|
||||
return 'danger';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRiskTypeLabel(value) {
|
||||
const option = riskTypeOptions.find((item) => item.value === value);
|
||||
return option?.label || value || '-';
|
||||
}
|
||||
|
||||
function resetCouponForm() {
|
||||
formTenantID.value = null;
|
||||
formTitle.value = '';
|
||||
@@ -315,6 +378,38 @@ async function loadGrants() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRisks() {
|
||||
risksLoading.value = true;
|
||||
try {
|
||||
const result = await CouponService.listCouponRisks({
|
||||
page: risksPage.value,
|
||||
limit: risksRows.value,
|
||||
risk_type: riskType.value || undefined,
|
||||
coupon_id: riskCouponID.value || undefined,
|
||||
tenant_id: riskTenantID.value || undefined,
|
||||
tenant_code: riskTenantCode.value,
|
||||
tenant_name: riskTenantName.value,
|
||||
keyword: riskKeyword.value,
|
||||
user_id: riskUserID.value || undefined,
|
||||
username: riskUsername.value,
|
||||
status: riskStatus.value || undefined,
|
||||
order_status: riskOrderStatus.value || undefined,
|
||||
created_at_from: riskCreatedAtFrom.value || undefined,
|
||||
created_at_to: riskCreatedAtTo.value || undefined,
|
||||
used_at_from: riskUsedAtFrom.value || undefined,
|
||||
used_at_to: riskUsedAtTo.value || undefined,
|
||||
sortField: riskSortField.value,
|
||||
sortOrder: riskSortOrder.value
|
||||
});
|
||||
risks.value = result.items;
|
||||
risksTotal.value = result.total;
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载异常记录', life: 4000 });
|
||||
} finally {
|
||||
risksLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
page.value = 1;
|
||||
loadCoupons();
|
||||
@@ -325,6 +420,11 @@ function onGrantSearch() {
|
||||
loadGrants();
|
||||
}
|
||||
|
||||
function onRiskSearch() {
|
||||
risksPage.value = 1;
|
||||
loadRisks();
|
||||
}
|
||||
|
||||
function onReset() {
|
||||
couponID.value = null;
|
||||
tenantID.value = null;
|
||||
@@ -359,6 +459,28 @@ function onGrantReset() {
|
||||
loadGrants();
|
||||
}
|
||||
|
||||
function onRiskReset() {
|
||||
riskType.value = '';
|
||||
riskCouponID.value = null;
|
||||
riskTenantID.value = null;
|
||||
riskTenantCode.value = '';
|
||||
riskTenantName.value = '';
|
||||
riskKeyword.value = '';
|
||||
riskUserID.value = null;
|
||||
riskUsername.value = '';
|
||||
riskStatus.value = '';
|
||||
riskOrderStatus.value = '';
|
||||
riskCreatedAtFrom.value = null;
|
||||
riskCreatedAtTo.value = null;
|
||||
riskUsedAtFrom.value = null;
|
||||
riskUsedAtTo.value = null;
|
||||
riskSortField.value = 'created_at';
|
||||
riskSortOrder.value = -1;
|
||||
risksPage.value = 1;
|
||||
risksRows.value = 10;
|
||||
loadRisks();
|
||||
}
|
||||
|
||||
function onPage(event) {
|
||||
page.value = (event.page ?? 0) + 1;
|
||||
rows.value = event.rows ?? rows.value;
|
||||
@@ -371,15 +493,28 @@ function onGrantPage(event) {
|
||||
loadGrants();
|
||||
}
|
||||
|
||||
function onRiskPage(event) {
|
||||
risksPage.value = (event.page ?? 0) + 1;
|
||||
risksRows.value = event.rows ?? risksRows.value;
|
||||
loadRisks();
|
||||
}
|
||||
|
||||
function onSort(event) {
|
||||
sortField.value = event.sortField ?? sortField.value;
|
||||
sortOrder.value = event.sortOrder ?? sortOrder.value;
|
||||
loadCoupons();
|
||||
}
|
||||
|
||||
function onRiskSort(event) {
|
||||
riskSortField.value = event.sortField ?? riskSortField.value;
|
||||
riskSortOrder.value = event.sortOrder ?? riskSortOrder.value;
|
||||
loadRisks();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCoupons();
|
||||
loadGrants();
|
||||
loadRisks();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -389,6 +524,7 @@ onMounted(() => {
|
||||
<TabList>
|
||||
<Tab value="coupons">券模板</Tab>
|
||||
<Tab value="grants">发放记录</Tab>
|
||||
<Tab value="risks">异常核查</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel value="coupons">
|
||||
@@ -625,6 +761,151 @@ onMounted(() => {
|
||||
</Column>
|
||||
</DataTable>
|
||||
</TabPanel>
|
||||
<TabPanel value="risks">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h4 class="m-0">异常核查</h4>
|
||||
</div>
|
||||
|
||||
<SearchPanel :loading="risksLoading" @search="onRiskSearch" @reset="onRiskReset">
|
||||
<SearchField label="异常类型">
|
||||
<Select v-model="riskType" :options="riskTypeOptions" optionLabel="label" optionValue="value" placeholder="请选择" class="w-full" />
|
||||
</SearchField>
|
||||
<SearchField label="CouponID">
|
||||
<InputNumber v-model="riskCouponID" :min="1" placeholder="精确匹配" class="w-full" />
|
||||
</SearchField>
|
||||
<SearchField label="TenantID">
|
||||
<InputNumber v-model="riskTenantID" :min="1" placeholder="精确匹配" class="w-full" />
|
||||
</SearchField>
|
||||
<SearchField label="Tenant Code">
|
||||
<InputText v-model="riskTenantCode" placeholder="模糊匹配" class="w-full" @keyup.enter="onRiskSearch" />
|
||||
</SearchField>
|
||||
<SearchField label="Tenant Name">
|
||||
<InputText v-model="riskTenantName" placeholder="模糊匹配" class="w-full" @keyup.enter="onRiskSearch" />
|
||||
</SearchField>
|
||||
<SearchField label="关键词">
|
||||
<IconField>
|
||||
<InputIcon>
|
||||
<i class="pi pi-search" />
|
||||
</InputIcon>
|
||||
<InputText v-model="riskKeyword" placeholder="标题/描述" class="w-full" @keyup.enter="onRiskSearch" />
|
||||
</IconField>
|
||||
</SearchField>
|
||||
<SearchField label="UserID">
|
||||
<InputNumber v-model="riskUserID" :min="1" placeholder="精确匹配" class="w-full" />
|
||||
</SearchField>
|
||||
<SearchField label="Username">
|
||||
<IconField>
|
||||
<InputIcon>
|
||||
<i class="pi pi-search" />
|
||||
</InputIcon>
|
||||
<InputText v-model="riskUsername" placeholder="模糊匹配" class="w-full" @keyup.enter="onRiskSearch" />
|
||||
</IconField>
|
||||
</SearchField>
|
||||
<SearchField label="券状态">
|
||||
<Select v-model="riskStatus" :options="riskStatusOptions" optionLabel="label" optionValue="value" placeholder="请选择" class="w-full" />
|
||||
</SearchField>
|
||||
<SearchField label="订单状态">
|
||||
<Select v-model="riskOrderStatus" :options="orderStatusOptions" optionLabel="label" optionValue="value" placeholder="请选择" class="w-full" />
|
||||
</SearchField>
|
||||
<SearchField label="领取时间 From">
|
||||
<DatePicker v-model="riskCreatedAtFrom" showIcon showButtonBar placeholder="开始时间" class="w-full" />
|
||||
</SearchField>
|
||||
<SearchField label="领取时间 To">
|
||||
<DatePicker v-model="riskCreatedAtTo" showIcon showButtonBar placeholder="结束时间" class="w-full" />
|
||||
</SearchField>
|
||||
<SearchField label="使用时间 From">
|
||||
<DatePicker v-model="riskUsedAtFrom" showIcon showButtonBar placeholder="开始时间" class="w-full" />
|
||||
</SearchField>
|
||||
<SearchField label="使用时间 To">
|
||||
<DatePicker v-model="riskUsedAtTo" showIcon showButtonBar placeholder="结束时间" class="w-full" />
|
||||
</SearchField>
|
||||
</SearchPanel>
|
||||
|
||||
<DataTable
|
||||
:value="risks"
|
||||
dataKey="id"
|
||||
:loading="risksLoading"
|
||||
lazy
|
||||
:paginator="true"
|
||||
:rows="risksRows"
|
||||
:totalRecords="risksTotal"
|
||||
:first="(risksPage - 1) * risksRows"
|
||||
:rowsPerPageOptions="[10, 20, 50, 100]"
|
||||
sortMode="single"
|
||||
:sortField="riskSortField"
|
||||
:sortOrder="riskSortOrder"
|
||||
@page="onRiskPage"
|
||||
@sort="onRiskSort"
|
||||
currentPageReportTemplate="显示第 {first} - {last} 条,共 {totalRecords} 条"
|
||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
|
||||
scrollable
|
||||
scrollHeight="flex"
|
||||
responsiveLayout="scroll"
|
||||
>
|
||||
<Column field="id" header="记录ID" sortable style="min-width: 8rem" />
|
||||
<Column header="异常类型" style="min-width: 18rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex flex-col">
|
||||
<Tag :value="resolveRiskTypeLabel(data.risk_type)" severity="danger" class="mb-1" />
|
||||
<span class="text-xs text-muted-color">{{ data.risk_reason || '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="优惠券" style="min-width: 16rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium">{{ data.coupon_title || '-' }}</span>
|
||||
<span class="text-xs text-muted-color">CouponID: {{ data.coupon_id ?? '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="租户" style="min-width: 16rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium">{{ data.tenant_name || '-' }}</span>
|
||||
<span class="text-xs text-muted-color">Code: {{ data.tenant_code || '-' }} / ID: {{ data.tenant_id ?? '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="用户" style="min-width: 14rem">
|
||||
<template #body="{ data }">
|
||||
<router-link v-if="data.user_id" class="inline-flex items-center gap-1 font-medium text-primary hover:underline" :to="`/superadmin/users/${data.user_id}`">
|
||||
<span class="truncate max-w-[200px]">{{ data.username || `ID:${data.user_id}` }}</span>
|
||||
<i class="pi pi-external-link text-xs" />
|
||||
</router-link>
|
||||
<div class="text-xs text-muted-color">ID: {{ data.user_id ?? '-' }}</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="status" header="券状态" style="min-width: 10rem">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.status_description || data.status || '-'" :severity="getGrantStatusSeverity(data.status)" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="订单信息" style="min-width: 18rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div>
|
||||
<span class="text-xs text-muted-color">OrderID:</span>
|
||||
<span class="ml-1">{{ data.order_id ? data.order_id : '-' }}</span>
|
||||
</div>
|
||||
<Tag :value="data.order_status_description || data.order_status || '-'" :severity="getOrderStatusSeverity(data.order_status)" />
|
||||
<div class="text-xs text-muted-color">实付:{{ formatCny(data.order_amount_paid) }}</div>
|
||||
<div class="text-xs text-muted-color">支付时间:{{ formatDate(data.paid_at) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="used_at" header="使用时间" sortable style="min-width: 14rem">
|
||||
<template #body="{ data }">
|
||||
{{ formatDate(data.used_at) }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="created_at" header="领取时间" sortable style="min-width: 14rem">
|
||||
<template #body="{ data }">
|
||||
{{ formatDate(data.created_at) }}
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user