merge(dev): HQ 推广码订单只计已完成并补快链
This commit is contained in:
@@ -229,7 +229,7 @@ export type AdminOrderRow = {
|
||||
proxyPartnerName?: string | null;
|
||||
proxyPartnerPhone?: string | null;
|
||||
isTest?: boolean;
|
||||
user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
|
||||
user?: { id: string; userNo: string; phone: string | null; nickname: string | null; hqRemark?: string | null };
|
||||
delivery?: {
|
||||
provider: string;
|
||||
trackingNo: string | null;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
@@ -153,6 +153,7 @@ type OrderExportFilters = {
|
||||
fulfillmentHold?: boolean;
|
||||
excludeTest?: boolean;
|
||||
deliveryType?: string;
|
||||
promoCodeId?: string;
|
||||
dateRange?: [Dayjs, Dayjs];
|
||||
};
|
||||
|
||||
@@ -212,6 +213,15 @@ const DATE_PRESETS: Array<{ key: 'day' | 'week' | 'month' | 'quarter' | 'year';
|
||||
{ key: 'year', label: '当年' },
|
||||
];
|
||||
|
||||
function formatUserWithRemark(user?: {
|
||||
userNo?: string;
|
||||
hqRemark?: string | null;
|
||||
} | null) {
|
||||
const userNo = user?.userNo || '—';
|
||||
const remark = user?.hqRemark?.trim();
|
||||
return remark ? `${userNo}(${remark})` : userNo;
|
||||
}
|
||||
|
||||
function formatBenefitBrief(row: AdminOrderRow) {
|
||||
const coupon = row.benefitCoupon;
|
||||
if (coupon) {
|
||||
@@ -247,6 +257,7 @@ function buildExportPayload(
|
||||
if (filters.deliveryType) payload.deliveryType = filters.deliveryType;
|
||||
if (filters.fulfillmentHold) payload.fulfillmentHold = true;
|
||||
if (filters.excludeTest) payload.excludeTest = true;
|
||||
if (filters.promoCodeId) payload.promoCodeId = filters.promoCodeId;
|
||||
if (filters.dateRange?.[0]) payload.createdFrom = filters.dateRange[0].format('YYYY-MM-DD');
|
||||
if (filters.dateRange?.[1]) payload.createdTo = filters.dateRange[1].format('YYYY-MM-DD');
|
||||
return payload;
|
||||
@@ -285,8 +296,15 @@ function warehouseToDefaults(wh: WarehouseOption, base?: ShipDefaults | null): S
|
||||
|
||||
export default function OrdersPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialOrderNo = searchParams.get('orderNo')?.trim() || '';
|
||||
const initialPromoCodeId = searchParams.get('promoCodeId')?.trim() || '';
|
||||
const initialStatusParam = searchParams.getAll('status').join(',');
|
||||
const initialStatuses = useMemo(
|
||||
() => initialStatusParam.split(',').map((s) => s.trim()).filter(Boolean),
|
||||
[initialStatusParam],
|
||||
);
|
||||
const [form] = Form.useForm();
|
||||
const [shipForm] = Form.useForm();
|
||||
const [logisticsForm] = Form.useForm();
|
||||
@@ -336,6 +354,20 @@ export default function OrdersPage() {
|
||||
}
|
||||
}, [form, initialOrderNo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialStatuses.length) {
|
||||
form.setFieldsValue({ status: initialStatuses });
|
||||
}
|
||||
}, [form, initialStatuses]);
|
||||
|
||||
useEffect(() => {
|
||||
const openOrderId = (location.state as { openOrderId?: string } | null)?.openOrderId;
|
||||
if (openOrderId) {
|
||||
void openDetail(openOrderId);
|
||||
navigate(`${location.pathname}${location.search}`, { replace: true, state: null });
|
||||
}
|
||||
}, [location.state, location.pathname, location.search, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialOrderNo || deepLinkOpenedRef.current || loading) return;
|
||||
const first = data?.items?.[0];
|
||||
@@ -366,13 +398,16 @@ export default function OrdersPage() {
|
||||
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
||||
if (initialOrderNo) qs.set('orderNo', initialOrderNo);
|
||||
if (values.orderNo) qs.set('orderNo', values.orderNo);
|
||||
for (const status of selectedStatuses(values.status)) qs.append('status', status);
|
||||
const statuses = selectedStatuses(values.status);
|
||||
const statusList = statuses.length ? statuses : initialStatuses;
|
||||
for (const status of statusList) qs.append('status', status);
|
||||
if (values.orderType) qs.set('orderType', values.orderType);
|
||||
if (values.cityId) qs.set('cityId', values.cityId);
|
||||
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
|
||||
if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true');
|
||||
if (values.excludeTest) qs.set('excludeTest', 'true');
|
||||
if (values.deliveryType) qs.set('deliveryType', values.deliveryType);
|
||||
if (initialPromoCodeId) qs.set('promoCodeId', initialPromoCodeId);
|
||||
if (values.dateRange?.[0]) qs.set('createdFrom', values.dateRange[0].format('YYYY-MM-DD'));
|
||||
if (values.dateRange?.[1]) qs.set('createdTo', values.dateRange[1].format('YYYY-MM-DD'));
|
||||
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
|
||||
@@ -380,7 +415,7 @@ export default function OrdersPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [form, page, pageSize, initialOrderNo]);
|
||||
}, [form, page, pageSize, initialOrderNo, initialPromoCodeId, initialStatuses]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
@@ -589,7 +624,16 @@ export default function OrdersPage() {
|
||||
}
|
||||
setExporting(true);
|
||||
try {
|
||||
const payload = buildExportPayload(scope, exportFormat, values, selectedRowKeys);
|
||||
const payload = buildExportPayload(
|
||||
scope,
|
||||
exportFormat,
|
||||
{
|
||||
...values,
|
||||
promoCodeId: initialPromoCodeId || values.promoCodeId,
|
||||
status: selectedStatuses(values.status).length ? values.status : initialStatuses,
|
||||
},
|
||||
selectedRowKeys,
|
||||
);
|
||||
const result = await request<OrderExportResult>('/admin/orders/export', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
@@ -618,14 +662,14 @@ export default function OrdersPage() {
|
||||
{
|
||||
title: '用户',
|
||||
key: 'user',
|
||||
width: 120,
|
||||
width: 200,
|
||||
render: (_, row) =>
|
||||
row.user?.id ? (
|
||||
<AdminPrimaryLink onClick={() => navigate('/users', { state: { openUserId: String(row.user!.id) } })}>
|
||||
{row.user.userNo}
|
||||
<AdminPrimaryLink onClick={() => navigate(`/users?userId=${encodeURIComponent(String(row.user!.id))}`)}>
|
||||
{formatUserWithRemark(row.user)}
|
||||
</AdminPrimaryLink>
|
||||
) : (
|
||||
(row.user?.userNo || '—')
|
||||
formatUserWithRemark(row.user)
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -778,6 +822,26 @@ export default function OrdersPage() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{initialPromoCodeId ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
closable
|
||||
style={{ marginBottom: 16 }}
|
||||
message={
|
||||
initialStatuses.includes('COMPLETED') && initialStatuses.length === 1
|
||||
? '已按推广码筛选已完成订单'
|
||||
: '已按推广码筛选订单'
|
||||
}
|
||||
action={
|
||||
<Button size="small" onClick={() => navigate('/orders')}>
|
||||
清除筛选
|
||||
</Button>
|
||||
}
|
||||
onClose={() => navigate('/orders')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Form form={form} layout="vertical" onFinish={() => { setPage(1); void load(); }}>
|
||||
<Row gutter={[16, 8]}>
|
||||
<Col xs={24} md={14} lg={12}>
|
||||
@@ -877,7 +941,16 @@ export default function OrdersPage() {
|
||||
) : <span />}
|
||||
<Space size={12} wrap>
|
||||
<Button type="primary" htmlType="submit">查询</Button>
|
||||
<Button onClick={() => { form.resetFields(); setPage(1); void load(); }}>重置</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.resetFields();
|
||||
setPage(1);
|
||||
if (initialPromoCodeId || initialOrderNo || initialStatuses.length) navigate('/orders');
|
||||
else void load();
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
{canDeleteOrders ? (
|
||||
<Button
|
||||
danger
|
||||
@@ -987,7 +1060,18 @@ export default function OrdersPage() {
|
||||
{[detail.proxyPartnerName, detail.proxyPartnerPhone].filter(Boolean).join(' / ') || '—'}
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
<Descriptions.Item label="用户">{detail.user?.userNo} / {detail.user?.nickname}</Descriptions.Item>
|
||||
<Descriptions.Item label="用户">
|
||||
{detail.user?.id ? (
|
||||
<AdminPrimaryLink
|
||||
onClick={() => navigate(`/users?userId=${encodeURIComponent(String(detail.user!.id))}`)}
|
||||
>
|
||||
{formatUserWithRemark(detail.user)}
|
||||
</AdminPrimaryLink>
|
||||
) : (
|
||||
formatUserWithRemark(detail.user)
|
||||
)}
|
||||
{detail.user?.nickname ? ` / ${detail.user.nickname}` : ''}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="商品额">¥{detail.productAmount}</Descriptions.Item>
|
||||
<Descriptions.Item label="实付">¥{detail.payAmount}</Descriptions.Item>
|
||||
<Descriptions.Item label="好客权益">¥{detail.benefitAmount}</Descriptions.Item>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Button, Form, Input, Modal, Select, Space, Table, Tag, Typography, message,
|
||||
Button, Form, Input, Modal, Select, Space, Table, Tag, Tooltip, Typography, message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
@@ -23,21 +23,6 @@ type Row = PromoCodeItem;
|
||||
|
||||
type SceneOption = { value: PromoCodeScene; label: string };
|
||||
|
||||
async function downloadQrcode(url: string, filename: string) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const blob = await res.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = objectUrl;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
} catch {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
}
|
||||
|
||||
export default function PromoCodesPage() {
|
||||
const navigate = useNavigate();
|
||||
const [filterForm] = Form.useForm();
|
||||
@@ -104,8 +89,26 @@ export default function PromoCodesPage() {
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '扫码', dataIndex: 'scanCount', width: 70 },
|
||||
{ title: '订单', dataIndex: 'orderCount', width: 70 },
|
||||
{
|
||||
title: '扫码',
|
||||
dataIndex: 'scanCount',
|
||||
width: 70,
|
||||
render: (v: number, row) => (
|
||||
<AdminPrimaryLink onClick={() => navigate(`/promo-codes/${row.id}?eventType=SCAN`)}>
|
||||
{v}
|
||||
</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: <Tooltip title="仅统计已完成订单">订单</Tooltip>,
|
||||
dataIndex: 'orderCount',
|
||||
width: 70,
|
||||
render: (v: number, row) => (
|
||||
<AdminPrimaryLink onClick={() => navigate(`/orders?promoCodeId=${row.id}&status=COMPLETED`)}>
|
||||
{v}
|
||||
</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '转化率',
|
||||
width: 90,
|
||||
@@ -119,24 +122,13 @@ export default function PromoCodesPage() {
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 220,
|
||||
width: 140,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Space size="small" wrap>
|
||||
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}`)}>
|
||||
详情
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
disabled={!row.qrcodeUrl}
|
||||
onClick={() => {
|
||||
if (!row.qrcodeUrl) return;
|
||||
void downloadQrcode(row.qrcodeUrl, `${row.code}-wxacode.png`);
|
||||
}}
|
||||
>
|
||||
下载小程序码
|
||||
</Button>
|
||||
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}/users`)}>
|
||||
关联用户
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
@@ -155,6 +155,7 @@ type BatchDeletePreview = {
|
||||
export default function UsersPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [form] = Form.useForm();
|
||||
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||
const [data, setData] = useState<Paginated<AdminUserRow> | null>(null);
|
||||
@@ -185,6 +186,8 @@ export default function UsersPage() {
|
||||
try {
|
||||
const values = form.getFieldsValue();
|
||||
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
||||
const userId = String(values.userId || '').trim();
|
||||
if (userId) qs.set('userId', userId);
|
||||
if (values.phone) qs.set('phone', values.phone);
|
||||
if (values.userNo) qs.set('userNo', values.userNo);
|
||||
if (values.deviceKey) qs.set('deviceKey', values.deviceKey);
|
||||
@@ -202,17 +205,26 @@ export default function UsersPage() {
|
||||
}
|
||||
}, [form, page, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
const userId = searchParams.get('userId');
|
||||
if (userId) form.setFieldsValue({ userId });
|
||||
}, [searchParams, form]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
}, [load, searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
const openUserId = (location.state as { openUserId?: string } | null)?.openUserId;
|
||||
if (openUserId) {
|
||||
void openDetail(openUserId);
|
||||
navigate(location.pathname, { replace: true, state: null });
|
||||
}
|
||||
}, [location.state, location.pathname, navigate]);
|
||||
if (!openUserId) return;
|
||||
form.setFieldsValue({ userId: openUserId });
|
||||
setPage(1);
|
||||
void openDetail(openUserId);
|
||||
navigate(`${location.pathname}?userId=${encodeURIComponent(openUserId)}`, {
|
||||
replace: true,
|
||||
state: null,
|
||||
});
|
||||
}, [location.state, location.pathname, navigate, form]);
|
||||
|
||||
async function openDetail(id: string) {
|
||||
const [res, logs] = await Promise.all([
|
||||
@@ -498,13 +510,30 @@ export default function UsersPage() {
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={() => {
|
||||
setPage(1);
|
||||
const userId = String(form.getFieldValue('userId') || '').trim();
|
||||
const current = searchParams.get('userId') ?? '';
|
||||
if (userId !== current) {
|
||||
if (userId) setSearchParams({ userId }, { replace: true });
|
||||
else setSearchParams({}, { replace: true });
|
||||
}
|
||||
void load();
|
||||
}}
|
||||
>
|
||||
<Form.Item name="phone" label="手机号">
|
||||
<Input placeholder="模糊搜索" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="userNo" label="用户编号">
|
||||
<Input placeholder="DK..." allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="userId" label="用户ID">
|
||||
<Input placeholder="精确匹配" allowClear style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="deviceKey" label="deviceKey">
|
||||
<Input placeholder="UUID" allowClear style={{ width: 200 }} />
|
||||
</Form.Item>
|
||||
@@ -526,7 +555,19 @@ export default function UsersPage() {
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">查询</Button>
|
||||
<Button onClick={() => { form.resetFields(); setPage(1); void load(); }}>重置</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.resetFields();
|
||||
setPage(1);
|
||||
if (searchParams.get('userId')) {
|
||||
setSearchParams({}, { replace: true });
|
||||
} else {
|
||||
void load();
|
||||
}
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, type CSSProperties } from 'react';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { useNavigate, useOutletContext } from 'react-router-dom';
|
||||
import { QuestionCircleOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
Tooltip,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
@@ -35,6 +37,9 @@ const descContentStyle: CSSProperties = {
|
||||
wordBreak: 'break-all',
|
||||
};
|
||||
|
||||
const ATTRIBUTION_HINT =
|
||||
'首次触达本推广码的用户。每人只归因一次、只归一个码:已登录用户扫码或带参进入时,若还没有归因记录,则记到本码;之后再扫其他码不会改。与「扫码注册」不同:注册只统计来源仍是自然量并被标记为本码的用户;已有其他来源(如分享)的用户仍可计入归因,但不计入扫码注册。';
|
||||
|
||||
async function downloadQrcode(url: string, filename: string) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
@@ -51,6 +56,7 @@ async function downloadQrcode(url: string, filename: string) {
|
||||
}
|
||||
|
||||
export default function PromoCodeDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const { detail, reload } = useOutletContext<PromoCodeDetailContext>();
|
||||
const [editForm] = Form.useForm();
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
@@ -209,14 +215,26 @@ export default function PromoCodeDetailPage() {
|
||||
|
||||
<Row gutter={16} style={{ marginTop: 16 }}>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Card
|
||||
size="small"
|
||||
hoverable
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/promo-codes/${detail.id}?eventType=SCAN`)}
|
||||
>
|
||||
<Statistic title="扫码进入次数" value={stats?.scanCount ?? detail.scanCount} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="归因用户数"
|
||||
title={
|
||||
<span>
|
||||
归因用户数
|
||||
<Tooltip title={ATTRIBUTION_HINT} overlayInnerStyle={{ maxWidth: 360 }}>
|
||||
<QuestionCircleOutlined style={{ marginLeft: 6, color: 'rgba(0,0,0,0.45)' }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
value={stats?.attributionCount ?? 0}
|
||||
/>
|
||||
</Card>
|
||||
@@ -230,8 +248,23 @@ export default function PromoCodeDetailPage() {
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="订单数" value={stats?.orderCount ?? detail.orderCount} />
|
||||
<Card
|
||||
size="small"
|
||||
hoverable
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/orders?promoCodeId=${detail.id}&status=COMPLETED`)}
|
||||
>
|
||||
<Statistic
|
||||
title={
|
||||
<span>
|
||||
订单数
|
||||
<Tooltip title="仅统计已完成订单" overlayInnerStyle={{ maxWidth: 280 }}>
|
||||
<QuestionCircleOutlined style={{ marginLeft: 6, color: 'rgba(0,0,0,0.45)' }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
value={stats?.orderCount ?? detail.orderCount}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Card,
|
||||
DatePicker,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
} from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../../lib/api';
|
||||
import { fmtTime } from '../../lib/constants';
|
||||
import { AdminPrimaryLink } from '../../components/AdminPrimaryLink';
|
||||
|
||||
type Props = {
|
||||
promoId: string;
|
||||
@@ -57,11 +59,45 @@ function buildEventsQs(
|
||||
return `/admin/promo-codes/${promoId}/metrics/events?${qs.toString()}`;
|
||||
}
|
||||
|
||||
function formatRefId(row: PromoMetricEventItem): string {
|
||||
if (row.orderId) return `订单 ${row.orderId}`;
|
||||
if (row.userId) return `用户 ${row.userId}`;
|
||||
if (row.sessionId) return `会话 ${row.sessionId}`;
|
||||
return '—';
|
||||
function parseEventType(raw: string | null): PromoMetricEventType | undefined {
|
||||
if (raw === 'SCAN' || raw === 'ATTRIBUTION' || raw === 'REGISTER' || raw === 'ORDER') return raw;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function EventRefLinks({ row }: { row: PromoMetricEventItem }) {
|
||||
const navigate = useNavigate();
|
||||
const links = [];
|
||||
if (row.orderId) {
|
||||
links.push(
|
||||
<AdminPrimaryLink
|
||||
key="order"
|
||||
onClick={() => {
|
||||
if (row.orderNo) {
|
||||
navigate(`/orders?orderNo=${encodeURIComponent(row.orderNo)}`);
|
||||
} else {
|
||||
navigate('/orders', { state: { openOrderId: String(row.orderId) } });
|
||||
}
|
||||
}}
|
||||
>
|
||||
订单 {row.orderNo || row.orderId}
|
||||
</AdminPrimaryLink>,
|
||||
);
|
||||
}
|
||||
if (row.userId) {
|
||||
links.push(
|
||||
<AdminPrimaryLink
|
||||
key="user"
|
||||
onClick={() => navigate('/users', { state: { openUserId: String(row.userId) } })}
|
||||
>
|
||||
用户 {row.userNo || row.userId}
|
||||
</AdminPrimaryLink>,
|
||||
);
|
||||
}
|
||||
if (links.length) {
|
||||
return <Space size={12}>{links}</Space>;
|
||||
}
|
||||
if (row.sessionId) return <>会话 {row.sessionId}</>;
|
||||
return <>—</>;
|
||||
}
|
||||
|
||||
function formatLocation(row: PromoMetricEventItem): string {
|
||||
@@ -71,15 +107,27 @@ function formatLocation(row: PromoMetricEventItem): string {
|
||||
}
|
||||
|
||||
export default function PromoCodeMetricsPanel({ promoId }: Props) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const urlEventType = parseEventType(searchParams.get('eventType'));
|
||||
const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs().subtract(6, 'day'), dayjs()]);
|
||||
const [granularity, setGranularity] = useState<'day' | 'hour'>('day');
|
||||
const [timeline, setTimeline] = useState<PromoMetricTimelineDto | null>(null);
|
||||
const [timelineLoading, setTimelineLoading] = useState(false);
|
||||
const [eventType, setEventType] = useState<PromoMetricEventType | undefined>();
|
||||
const [eventType, setEventType] = useState<PromoMetricEventType | undefined>(urlEventType);
|
||||
const [eventsPage, setEventsPage] = useState(1);
|
||||
const [events, setEvents] = useState<Paginated<PromoMetricEventItem> | null>(null);
|
||||
const [eventsLoading, setEventsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setEventType(urlEventType);
|
||||
setEventsPage(1);
|
||||
if (urlEventType) {
|
||||
window.requestAnimationFrame(() => {
|
||||
document.getElementById('promo-metric-events')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
});
|
||||
}
|
||||
}, [urlEventType]);
|
||||
|
||||
const loadTimeline = useCallback(() => {
|
||||
setTimelineLoading(true);
|
||||
return request<PromoMetricTimelineDto>(buildTimelineQs(promoId, range, granularity))
|
||||
@@ -164,7 +212,7 @@ export default function PromoCodeMetricsPanel({ promoId }: Props) {
|
||||
{
|
||||
title: 'ID',
|
||||
key: 'ref',
|
||||
render: (_, row) => formatRefId(row),
|
||||
render: (_, row) => <EventRefLinks row={row} />,
|
||||
},
|
||||
{
|
||||
title: 'IP',
|
||||
@@ -220,7 +268,7 @@ export default function PromoCodeMetricsPanel({ promoId }: Props) {
|
||||
notMerge
|
||||
/>
|
||||
|
||||
<Typography.Title level={5} style={{ marginTop: 24, marginBottom: 12 }}>
|
||||
<Typography.Title id="promo-metric-events" level={5} style={{ marginTop: 24, marginBottom: 12 }}>
|
||||
事件日志
|
||||
</Typography.Title>
|
||||
<Space wrap style={{ marginBottom: 12 }}>
|
||||
@@ -232,6 +280,10 @@ export default function PromoCodeMetricsPanel({ promoId }: Props) {
|
||||
onChange={(v) => {
|
||||
setEventType(v);
|
||||
setEventsPage(1);
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (v) next.set('eventType', v);
|
||||
else next.delete('eventType');
|
||||
setSearchParams(next, { replace: true });
|
||||
}}
|
||||
options={Object.entries(PROMO_METRIC_EVENT_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useNavigate, useOutletContext, useParams } from 'react-router-dom';
|
||||
import { Button, Space, Table, Tag } from 'antd';
|
||||
import { Button, Space, Table, Tag, Tooltip } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
USER_SOURCE_TYPE_LABELS,
|
||||
@@ -66,7 +66,7 @@ export default function PromoCodeUsersPage() {
|
||||
width: 160,
|
||||
render: (v) => (v ? fmtTime(v) : '—'),
|
||||
},
|
||||
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
|
||||
{ title: <Tooltip title="仅统计已完成订单">订单数</Tooltip>, dataIndex: 'orderCount', width: 80 },
|
||||
{ title: '注册时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
|
||||
@@ -18,6 +18,16 @@ export interface AdminOrdersListQuery extends AdminListQuery {
|
||||
deliveryType?: string;
|
||||
}
|
||||
|
||||
export interface AdminUsersListQuery extends AdminListQuery {
|
||||
phone?: string;
|
||||
userNo?: string;
|
||||
userId?: string;
|
||||
deviceKey?: string;
|
||||
phoneVerified?: string;
|
||||
status?: number;
|
||||
excludeTest?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateAdminUserRequest {
|
||||
hqRemark: string;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ export type PromoCodeItem = {
|
||||
qrcodeId: string;
|
||||
status: PromoCodeStatus;
|
||||
scanCount: number;
|
||||
/** 已完成订单数(status=COMPLETED) */
|
||||
orderCount: number;
|
||||
landingUrl: string;
|
||||
qrcodeUrl?: string | null;
|
||||
@@ -47,6 +48,7 @@ export type PromoCodeItem = {
|
||||
export type PromoCodeStats = {
|
||||
/** 扫码进入次数 */
|
||||
scanCount: number;
|
||||
/** 已完成订单数(status=COMPLETED) */
|
||||
orderCount: number;
|
||||
conversionRate: number;
|
||||
/** 归因用户数(user_promo_attribution) */
|
||||
@@ -66,6 +68,7 @@ export type PromoCodeAttributedUser = {
|
||||
sourceType: string;
|
||||
sourceRefId: string | null;
|
||||
firstTouchAt: string | null;
|
||||
/** 该用户已完成订单数 */
|
||||
orderCount: number;
|
||||
createdAt: string;
|
||||
};
|
||||
@@ -98,7 +101,9 @@ export type PromoMetricEventItem = {
|
||||
id: string;
|
||||
eventType: PromoMetricEventType;
|
||||
userId: string | null;
|
||||
userNo?: string | null;
|
||||
orderId: string | null;
|
||||
orderNo?: string | null;
|
||||
sessionId: string | null;
|
||||
clientIp: string | null;
|
||||
ipProvince: string | null;
|
||||
|
||||
@@ -100,6 +100,27 @@ export class WechatTradeManageService {
|
||||
remark,
|
||||
}),
|
||||
});
|
||||
if (order.promoCodeId) {
|
||||
const already = await tx.logPromoEvent.findFirst({
|
||||
where: { orderId: order.id, eventType: 'ORDER' },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!already) {
|
||||
await tx.commonPromoCode.update({
|
||||
where: { id: order.promoCodeId },
|
||||
data: { orderCount: { increment: 1 } },
|
||||
});
|
||||
await tx.logPromoEvent.create({
|
||||
data: {
|
||||
promoCodeId: order.promoCodeId,
|
||||
eventType: 'ORDER',
|
||||
userId: order.userId,
|
||||
orderId: order.id,
|
||||
clientIp: order.clientIp,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await this.logEvent(evt, order.id, 'SUCCESS', methodLabel);
|
||||
|
||||
@@ -37,6 +37,7 @@ type OrderFilterInput = Pick<
|
||||
| 'createdTo'
|
||||
| 'excludeTest'
|
||||
| 'deliveryType'
|
||||
| 'promoCodeId'
|
||||
>;
|
||||
|
||||
@Injectable()
|
||||
@@ -94,7 +95,7 @@ export class AdminOrdersService {
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: {
|
||||
user: { select: { id: true, userNo: true, phone: true, nickname: true } },
|
||||
user: { select: { id: true, userNo: true, phone: true, nickname: true, hqRemark: true } },
|
||||
delivery: {
|
||||
select: {
|
||||
provider: true,
|
||||
@@ -190,6 +191,10 @@ export class AdminOrdersService {
|
||||
if (query.createdTo) where.createdAt.lte = this.endOfDay(query.createdTo);
|
||||
}
|
||||
if (query.excludeTest) where.isTest = false;
|
||||
const promoCodeId = query.promoCodeId?.trim();
|
||||
if (promoCodeId && /^\d+$/.test(promoCodeId)) {
|
||||
where.promoCodeId = BigInt(promoCodeId);
|
||||
}
|
||||
|
||||
return where;
|
||||
}
|
||||
@@ -252,6 +257,7 @@ export class AdminOrdersService {
|
||||
userNo: true,
|
||||
phone: true,
|
||||
nickname: true,
|
||||
hqRemark: true,
|
||||
deviceKey: true,
|
||||
phoneVerifiedAt: true,
|
||||
},
|
||||
|
||||
@@ -81,6 +81,13 @@ export class AdminUsersService {
|
||||
|
||||
if (query.phone) where.phone = { contains: query.phone };
|
||||
if (query.userNo) where.userNo = { contains: query.userNo };
|
||||
const userId = query.userId?.trim();
|
||||
if (userId) {
|
||||
if (!/^\d+$/.test(userId)) {
|
||||
return serializeBigInt({ items: [], total: 0, page, pageSize });
|
||||
}
|
||||
where.id = BigInt(userId);
|
||||
}
|
||||
if (query.deviceKey) where.deviceKey = query.deviceKey;
|
||||
if (query.status !== undefined) where.status = query.status;
|
||||
if (query.phoneVerified === '1') where.phoneVerifiedAt = { not: null };
|
||||
|
||||
@@ -48,6 +48,11 @@ export class AdminUsersQueryDto extends PaginationQueryDto {
|
||||
@IsString()
|
||||
userNo?: string;
|
||||
|
||||
/** 精确匹配用户主键,供订单等页的用户快链带入筛选 */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
userId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
deviceKey?: string;
|
||||
@@ -116,6 +121,11 @@ export class AdminOrdersQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsIn(['LOCAL', 'CROSS_CITY', 'ON_SITE_PICKUP'])
|
||||
deliveryType?: string;
|
||||
|
||||
/** 按下单时绑定的推广码筛选 */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
promoCodeId?: string;
|
||||
}
|
||||
|
||||
/** HQ 订单导出(筛选 + 勾选范围) */
|
||||
@@ -174,6 +184,10 @@ export class AdminOrdersExportDto {
|
||||
@IsOptional()
|
||||
@IsIn(['LOCAL', 'CROSS_CITY', 'ON_SITE_PICKUP'])
|
||||
deliveryType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
promoCodeId?: string;
|
||||
}
|
||||
|
||||
/** 概览页用户/订单 ECharts 聚合筛选 */
|
||||
|
||||
@@ -151,7 +151,7 @@ export class PromoCodeService {
|
||||
});
|
||||
}
|
||||
|
||||
private mapRow(row: PromoRow) {
|
||||
private mapRow(row: PromoRow, orderCount?: number) {
|
||||
return serializeBigInt({
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
@@ -161,7 +161,7 @@ export class PromoCodeService {
|
||||
status: row.status,
|
||||
remark: row.remark,
|
||||
scanCount: row.scanCount,
|
||||
orderCount: row.orderCount,
|
||||
orderCount: orderCount ?? row.orderCount,
|
||||
landingUrl: buildLandingUrl(row.code, row.qrcodeId),
|
||||
qrcodeUrl: row.qrcodeResource?.url ?? null,
|
||||
ownerUser: this.mapOwnerUser(row.ownerUser),
|
||||
@@ -207,9 +207,10 @@ export class PromoCodeService {
|
||||
}),
|
||||
this.prisma.commonPromoCode.count({ where }),
|
||||
]);
|
||||
const completedByPromo = await this.completedOrderCounts(items.map((r) => r.id));
|
||||
|
||||
return serializeBigInt({
|
||||
items: items.map((r) => this.mapRow(r)),
|
||||
items: items.map((r) => this.mapRow(r, completedByPromo.get(r.id.toString()) ?? 0)),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
@@ -223,7 +224,7 @@ export class PromoCodeService {
|
||||
});
|
||||
if (!row) throw new NotFoundException('推广码不存在');
|
||||
const stats = await this.statsFromRow(row);
|
||||
return serializeBigInt({ ...this.mapRow(row), stats });
|
||||
return serializeBigInt({ ...this.mapRow(row, stats.orderCount), stats });
|
||||
}
|
||||
|
||||
private async resolveOwnerUserId(ownerUserId?: string) {
|
||||
@@ -519,6 +520,40 @@ export class PromoCodeService {
|
||||
this.logPromoMetric(promoCodeId, 'ORDER', { clientIp }, { userId, orderId });
|
||||
}
|
||||
|
||||
/** 订单完成时计入推广码订单数(下单/待付款不计入) */
|
||||
async recordCompletedOrder(
|
||||
promoCodeId: bigint,
|
||||
orderId: bigint,
|
||||
userId: bigint,
|
||||
clientIp?: string,
|
||||
): Promise<void> {
|
||||
const already = await this.prisma.logPromoEvent.findFirst({
|
||||
where: { orderId, eventType: 'ORDER' },
|
||||
select: { id: true },
|
||||
});
|
||||
if (already) return;
|
||||
await this.prisma.commonPromoCode.update({
|
||||
where: { id: promoCodeId },
|
||||
data: { orderCount: { increment: 1 } },
|
||||
});
|
||||
this.logPromoOrderEvent(promoCodeId, orderId, userId, clientIp);
|
||||
}
|
||||
|
||||
private async completedOrderCounts(promoIds: bigint[]): Promise<Map<string, number>> {
|
||||
const map = new Map<string, number>();
|
||||
if (!promoIds.length) return map;
|
||||
const rows = await this.prisma.order.groupBy({
|
||||
by: ['promoCodeId'],
|
||||
where: { promoCodeId: { in: promoIds }, status: 'COMPLETED' },
|
||||
_count: { _all: true },
|
||||
});
|
||||
for (const row of rows) {
|
||||
if (row.promoCodeId == null) continue;
|
||||
map.set(row.promoCodeId.toString(), row._count._all);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private logPromoMetric(
|
||||
promoCodeId: bigint,
|
||||
eventType: PromoMetricEventType,
|
||||
@@ -610,12 +645,37 @@ export class PromoCodeService {
|
||||
this.prisma.logPromoEvent.count({ where }),
|
||||
]);
|
||||
|
||||
const userIds = [...new Set(items.map((row) => row.userId).filter((id): id is bigint => id != null))];
|
||||
const orderIds = [...new Set(items.map((row) => row.orderId).filter((id): id is bigint => id != null))];
|
||||
const [users, orders] = await Promise.all([
|
||||
userIds.length
|
||||
? this.prisma.user.findMany({
|
||||
where: { id: { in: userIds } },
|
||||
select: { id: true, userNo: true },
|
||||
})
|
||||
: [],
|
||||
orderIds.length
|
||||
? this.prisma.order.findMany({
|
||||
where: { id: { in: orderIds } },
|
||||
select: { id: true, orderNo: true },
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
const userNoById = new Map<string, string | null>(
|
||||
users.map((u): [string, string | null] => [u.id.toString(), u.userNo]),
|
||||
);
|
||||
const orderNoById = new Map<string, string>(
|
||||
orders.map((o): [string, string] => [o.id.toString(), o.orderNo]),
|
||||
);
|
||||
|
||||
return serializeBigInt({
|
||||
items: items.map((row) => ({
|
||||
id: row.id,
|
||||
eventType: row.eventType,
|
||||
userId: row.userId,
|
||||
userNo: row.userId ? userNoById.get(row.userId.toString()) ?? null : null,
|
||||
orderId: row.orderId,
|
||||
orderNo: row.orderId ? orderNoById.get(row.orderId.toString()) ?? null : null,
|
||||
sessionId: row.sessionId,
|
||||
clientIp: row.clientIp,
|
||||
ipProvince: row.ipProvince,
|
||||
@@ -636,12 +696,9 @@ export class PromoCodeService {
|
||||
if (!promo) throw new NotFoundException('推广码不存在');
|
||||
}
|
||||
|
||||
private async statsFromRow(row: { id: bigint; scanCount: number; orderCount: number }) {
|
||||
private async statsFromRow(row: { id: bigint; scanCount: number }) {
|
||||
const scanCount = row.scanCount;
|
||||
const orderCount = row.orderCount;
|
||||
const conversionRate =
|
||||
scanCount > 0 ? Math.round((orderCount / scanCount) * 1000) / 10 : 0;
|
||||
const [attributionCount, sourceMarkedCount] = await Promise.all([
|
||||
const [attributionCount, sourceMarkedCount, orderCount] = await Promise.all([
|
||||
this.prisma.userPromoAttribution.count({
|
||||
where: { promoCodeId: row.id },
|
||||
}),
|
||||
@@ -652,7 +709,12 @@ export class PromoCodeService {
|
||||
mergedIntoUserId: null,
|
||||
},
|
||||
}),
|
||||
this.prisma.order.count({
|
||||
where: { promoCodeId: row.id, status: 'COMPLETED' },
|
||||
}),
|
||||
]);
|
||||
const conversionRate =
|
||||
scanCount > 0 ? Math.round((orderCount / scanCount) * 1000) / 10 : 0;
|
||||
return {
|
||||
scanCount,
|
||||
orderCount,
|
||||
@@ -695,7 +757,7 @@ export class PromoCodeService {
|
||||
sourceRefId: true,
|
||||
createdAt: true,
|
||||
promoTouch: { select: { firstTouchAt: true, promoCodeId: true } },
|
||||
_count: { select: { orders: true } },
|
||||
_count: { select: { orders: { where: { status: 'COMPLETED' } } } },
|
||||
},
|
||||
}),
|
||||
this.prisma.user.count({ where }),
|
||||
|
||||
@@ -357,25 +357,9 @@ export class TradeService {
|
||||
include: { product: true, imageResource: true },
|
||||
});
|
||||
|
||||
if (promoCodeId) {
|
||||
await tx.commonPromoCode.update({
|
||||
where: { id: promoCodeId },
|
||||
data: { orderCount: { increment: 1 } },
|
||||
});
|
||||
}
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
if (promoCodeId) {
|
||||
this.promoCodeService.logPromoOrderEvent(
|
||||
promoCodeId,
|
||||
order.id,
|
||||
userId,
|
||||
location.clientIp ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
this.analyticsService.trackOneSafe(userId, this.resolveTrackedUserClientApp(clientApp), {
|
||||
eventName: 'order_submit',
|
||||
refType: 'ORDER',
|
||||
@@ -526,6 +510,15 @@ export class TradeService {
|
||||
});
|
||||
if (!order) return;
|
||||
|
||||
if (order.status === 'COMPLETED' && order.promoCodeId) {
|
||||
await this.promoCodeService.recordCompletedOrder(
|
||||
order.promoCodeId,
|
||||
order.id,
|
||||
order.userId,
|
||||
order.clientIp ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
if (!order.isTest) {
|
||||
const unit = order.saleUnit === 'BOX' ? '箱' : '瓶';
|
||||
const spec = (order.productSpec || '').trim();
|
||||
@@ -1593,6 +1586,15 @@ export class TradeService {
|
||||
});
|
||||
});
|
||||
|
||||
if (targetStatus === 'COMPLETED' && currentStatus !== 'COMPLETED' && order.promoCodeId) {
|
||||
await this.promoCodeService.recordCompletedOrder(
|
||||
order.promoCodeId,
|
||||
order.id,
|
||||
order.userId,
|
||||
order.clientIp ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
// 发货信息管理:进入 SHIPPING 时向微信录入(解冻结算前置)
|
||||
if (targetStatus === 'SHIPPING' && currentStatus !== 'SHIPPING') {
|
||||
this.wechatOrderShipping.uploadForOrderSafe(orderId);
|
||||
@@ -1934,25 +1936,9 @@ export class TradeService {
|
||||
}),
|
||||
});
|
||||
|
||||
if (promoCodeId) {
|
||||
await tx.commonPromoCode.update({
|
||||
where: { id: promoCodeId },
|
||||
data: { orderCount: { increment: 1 } },
|
||||
});
|
||||
}
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
if (promoCodeId) {
|
||||
this.promoCodeService.logPromoOrderEvent(
|
||||
promoCodeId,
|
||||
order.id,
|
||||
user.id,
|
||||
location.clientIp ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
|
||||
partnerAccountId: primary.id,
|
||||
eventName: 'partner_proxy_order_create',
|
||||
@@ -2347,25 +2333,9 @@ export class TradeService {
|
||||
}),
|
||||
});
|
||||
|
||||
if (promoCodeId) {
|
||||
await tx.commonPromoCode.update({
|
||||
where: { id: promoCodeId },
|
||||
data: { orderCount: { increment: 1 } },
|
||||
});
|
||||
}
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
if (promoCodeId) {
|
||||
this.promoCodeService.logPromoOrderEvent(
|
||||
promoCodeId,
|
||||
order.id,
|
||||
user.id,
|
||||
location.clientIp ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: order.id.toString(),
|
||||
orderNo: order.orderNo,
|
||||
|
||||
Reference in New Issue
Block a user