feat: add coupon risk review

This commit is contained in:
2026-01-15 17:01:36 +08:00
parent c8ec0af07f
commit ba1d120c84
10 changed files with 1255 additions and 8 deletions

View File

@@ -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)
};
}
};

View File

@@ -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>