推广码后端页面优化

This commit is contained in:
2026-07-23 00:43:49 +08:00
parent cd6b82abd8
commit e93e4a7721
11 changed files with 456 additions and 260 deletions
+244 -236
View File
@@ -1,236 +1,244 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Button, Form, Input, Modal, Select, Space, Table, Tag, Typography, message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
PROMO_CODE_SCENE_LABELS,
PROMO_CODE_STATUS_LABELS,
promoConversion,
type PromoCodeItem,
type PromoCodeScene,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
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();
const [createForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const [scenes, setScenes] = useState<SceneOption[]>(
Object.entries(PROMO_CODE_SCENE_LABELS).map(([value, label]) => ({
value: value as PromoCodeScene,
label,
})),
);
const [createOpen, setCreateOpen] = useState(false);
const [creating, setCreating] = useState(false);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/promo-codes',
() => {
const qs = new URLSearchParams();
if (filters.name) qs.set('name', filters.name);
if (filters.code) qs.set('code', filters.code);
if (filters.status) qs.set('status', filters.status);
if (filters.scene) qs.set('scene', filters.scene);
return qs;
},
[filters],
);
async function loadScenes() {
try {
const list = await request<SceneOption[]>('/admin/promo-codes/scenes');
if (list.length) setScenes(list);
} catch {
/* 使用本地默认场景 */
}
}
useEffect(() => {
void loadScenes();
}, []);
const columns: ColumnsType<Row> = [
{ title: '名称', dataIndex: 'name', width: 160, ellipsis: true },
{ title: '码值', dataIndex: 'code', width: 110 },
{
title: '场景',
dataIndex: 'scene',
width: 110,
render: (s: PromoCodeScene) => PROMO_CODE_SCENE_LABELS[s] || s,
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s) => (
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>
{PROMO_CODE_STATUS_LABELS[s as keyof typeof PROMO_CODE_STATUS_LABELS] || s}
</Tag>
),
},
{ title: '扫码', dataIndex: 'scanCount', width: 70 },
{ title: '订单', dataIndex: 'orderCount', width: 70 },
{
title: '转化率',
width: 90,
render: (_, row) => promoConversion(row.scanCount, row.orderCount),
},
{
title: '渠道负责人',
width: 120,
render: (_, row) => row.ownerUser?.userNo || row.ownerUser?.phone || '—',
},
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 220,
fixed: 'right',
render: (_, row) => (
<Space size="small" wrap>
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}`)}>
</Button>
<Button
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Button, Form, Input, Modal, Select, Space, Table, Tag, Typography, message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
PROMO_CODE_SCENE_LABELS,
PROMO_CODE_STATUS_LABELS,
promoConversion,
type PromoCodeItem,
type PromoCodeScene,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
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();
const [createForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const [scenes, setScenes] = useState<SceneOption[]>(
Object.entries(PROMO_CODE_SCENE_LABELS).map(([value, label]) => ({
value: value as PromoCodeScene,
label,
})),
);
const [createOpen, setCreateOpen] = useState(false);
const [creating, setCreating] = useState(false);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/promo-codes',
() => {
const qs = new URLSearchParams();
if (filters.name) qs.set('name', filters.name);
if (filters.code) qs.set('code', filters.code);
if (filters.status) qs.set('status', filters.status);
if (filters.scene) qs.set('scene', filters.scene);
return qs;
},
[filters],
);
async function loadScenes() {
try {
const list = await request<SceneOption[]>('/admin/promo-codes/scenes');
if (list.length) setScenes(list);
} catch {
/* 使用本地默认场景 */
}
}
useEffect(() => {
void loadScenes();
}, []);
const columns: ColumnsType<Row> = [
{ title: '名称', dataIndex: 'name', width: 160, ellipsis: true },
{ title: '码值', dataIndex: 'code', width: 110 },
{
title: '场景',
dataIndex: 'scene',
width: 110,
render: (s: PromoCodeScene) => PROMO_CODE_SCENE_LABELS[s] || s,
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s) => (
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>
{PROMO_CODE_STATUS_LABELS[s as keyof typeof PROMO_CODE_STATUS_LABELS] || s}
</Tag>
),
},
{ title: '扫码', dataIndex: 'scanCount', width: 70 },
{ title: '订单', dataIndex: 'orderCount', width: 70 },
{
title: '转化率',
width: 90,
render: (_, row) => promoConversion(row.scanCount, row.orderCount),
},
{
title: '渠道负责人',
width: 120,
render: (_, row) => row.ownerUser?.userNo || row.ownerUser?.phone || '—',
},
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 220,
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>
</Space>
),
},
];
async function handleCreate(values: Record<string, string>) {
setCreating(true);
try {
const created = await request<PromoCodeItem>('/admin/promo-codes', {
method: 'POST',
body: JSON.stringify({
name: values.name,
code: values.code?.trim() || undefined,
scene: values.scene,
ownerUserId: values.ownerUserId?.trim() || undefined,
remark: values.remark?.trim() || undefined,
page: values.page?.trim() || undefined,
}),
});
message.success('推广码已创建');
setCreateOpen(false);
createForm.resetFields();
void reload();
navigate(`/promo-codes/${created.id}`);
} catch (e) {
message.error(e instanceof Error ? e.message : '创建失败');
} finally {
setCreating(false);
}
}
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>广</Typography.Title>
<Button type="primary" onClick={() => setCreateOpen(true)}>广</Button>
</div>
<Form
form={filterForm}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => { setFilters(v); setPage(1); }}
>
<Form.Item name="name" label="名称"><Input allowClear /></Form.Item>
<Form.Item name="code" label="码值"><Input allowClear /></Form.Item>
<Form.Item name="scene" label="场景">
<Select allowClear style={{ width: 130 }} options={scenes} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 100 }}
options={Object.entries(PROMO_CODE_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form>
<Table
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1200 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
}}
/>
<Modal
title="创建推广码"
open={createOpen}
onCancel={() => setCreateOpen(false)}
footer={null}
destroyOnClose
>
<Form form={createForm} layout="vertical" onFinish={handleCreate} initialValues={{ scene: 'ONLINE_LINK' }}>
<Form.Item name="name" label="推广码名称" rules={[{ required: true, message: '请填写名称' }]}>
<Input placeholder="如:郑州品鉴会、门店地推" />
</Form.Item>
<Form.Item name="scene" label="场景" rules={[{ required: true }]}>
<Select options={scenes} />
</Form.Item>
<Form.Item name="code" label="自定义码值(选填)">
<Input placeholder="留空自动生成,如 DKDEMO1" />
</Form.Item>
<Form.Item
name="page"
label="小程序落地页(选填)"
extra="如 pages/home/index;留空则使用服务端环境变量 WX_MINI_PROMO_PAGE"
>
<Input placeholder="pages/home/index" />
</Form.Item>
<Form.Item name="ownerUserId" label="关联用户 ID(选填)">
<Input placeholder="渠道负责人,填写用户数据库 ID" />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} placeholder="渠道说明、活动备注等" />
</Form.Item>
<Button type="primary" htmlType="submit" loading={creating} block>
</Button>
</Form>
</Modal>
</div>
);
}
@@ -155,9 +155,6 @@ export default function PromoCodeDetailPage() {
<Descriptions.Item label="二维码 ID" span={2}>
<Typography.Text copyable={{ text: detail.qrcodeId }}>{detail.qrcodeId}</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="H5 落地链接" span={2}>
<Typography.Text copyable={{ text: detail.landingUrl }}>{detail.landingUrl}</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="小程序码 OSS" span={2}>
{detail.qrcodeUrl ? (
<Typography.Text copyable={{ text: detail.qrcodeUrl }} ellipsis>
@@ -179,7 +176,15 @@ export default function PromoCodeDetailPage() {
<Row gutter={16} style={{ marginTop: 16 }}>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="扫码次数" value={stats?.scanCount ?? detail.scanCount} />
<Statistic title="扫码进入次数" value={stats?.scanCount ?? detail.scanCount} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="扫码注册用户数"
value={stats?.registerCount ?? stats?.sourceMarkedCount ?? 0}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
@@ -195,11 +200,6 @@ export default function PromoCodeDetailPage() {
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="来源标记用户" value={stats?.sourceMarkedCount ?? '—'} />
</Card>
</Col>
</Row>
<Modal
@@ -3,6 +3,7 @@ import { captureIosJssdkEntryUrl } from '@dukang/weixin-sdk';
import { useEffect, useRef } from 'react';
import { finishLoginNavigate, forceReloadAfterAccountMerge, goLogin } from '../lib/auth-nav';
import { toast } from '../lib/api';
import { capturePromoSceneAndTouchScan } from '../lib/promo';
import { saveWechatLoginResult } from '../lib/pay-wechat';
import { applyWechatShare } from '../lib/wechat-share';
import { handleWechatAuthCallback } from '../lib/wechat-auth';
@@ -29,13 +30,16 @@ function currentPagePathWithQuery(): string {
}
/**
* H5 App 根节点专用:不可用 useDidShowApp 无页面 Context
* - 首次进页:捕获 iOS 签名 URL + 默认分享 + OAuth code 回调
* - 路由/回前台:刷新默认分享卡片
* H5 App 根节点iOS 签名 URL + 默认分享 + OAuth code 回调
* 小程序:冷启动时捕获推广码 scene 并回传扫码埋点。
*/
export default function WechatShareBootstrap() {
const handlingCode = useRef(false);
useEffect(() => {
void capturePromoSceneAndTouchScan();
}, []);
useEffect(() => {
if (process.env.TARO_ENV !== 'h5') return;
if (typeof window === 'undefined') return;
+129
View File
@@ -0,0 +1,129 @@
import Taro from '@tarojs/taro';
import { request } from './api';
const PROMO_ID_KEY = 'dukang_promo_id';
/** 同一次进入只 touch 一次扫码计数,避免首页反复 onShow 刷量 */
let lastScanTouchKey = '';
function safeDecode(raw: string): string {
try {
return decodeURIComponent(raw);
} catch {
return raw;
}
}
function normalizePromoId(raw: unknown): string | null {
if (raw == null || raw === '') return null;
const s = safeDecode(String(raw)).trim();
// 小程序码 scene 写入的是推广活动数字 ID
if (!/^\d+$/.test(s)) return null;
return s;
}
type EnterOptionsLike = {
scene?: string | number;
query?: Record<string, string | undefined>;
path?: string;
};
/** 从启动/进入参数解析推广活动 ID(优先 query.scene,与 getwxacodeunlimit 一致) */
export function extractPromoIdFromEnterOptions(opts?: EnterOptionsLike | null): string | null {
if (!opts) return null;
const q = opts.query ?? {};
return (
normalizePromoId(q.scene) ||
normalizePromoId(q.promoId) ||
normalizePromoId(q.pid) ||
null
);
}
export function getStoredPromoId(): string | null {
try {
const v = Taro.getStorageSync(PROMO_ID_KEY);
return normalizePromoId(v);
} catch {
return null;
}
}
export function setStoredPromoId(promoId: string) {
const id = normalizePromoId(promoId);
if (!id) return;
try {
Taro.setStorageSync(PROMO_ID_KEY, id);
} catch {
/* ignore */
}
}
function readEnterOptions(): EnterOptionsLike | null {
try {
if (typeof Taro.getEnterOptionsSync === 'function') {
return Taro.getEnterOptionsSync() as EnterOptionsLike;
}
} catch {
/* ignore */
}
try {
if (typeof Taro.getLaunchOptionsSync === 'function') {
return Taro.getLaunchOptionsSync() as EnterOptionsLike;
}
} catch {
/* ignore */
}
// H5:从 URL query 读取
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined') {
const params = new URLSearchParams(window.location.search);
return {
query: {
scene: params.get('scene') || undefined,
promoId: params.get('promoId') || undefined,
pid: params.get('pid') || undefined,
},
};
}
return null;
}
/**
* 主页面进入时:取出 scene(活动 ID)本地缓存,并回传 /promo/touch 累加扫码次数。
* 同一进入会话只计一次扫码。
*/
export async function capturePromoSceneAndTouchScan(): Promise<void> {
const opts = readEnterOptions();
const fromEnter = extractPromoIdFromEnterOptions(opts);
if (fromEnter) {
setStoredPromoId(fromEnter);
const touchKey = `${fromEnter}|${opts?.path || ''}|${JSON.stringify(opts?.query || {})}|${String(opts?.scene ?? '')}`;
if (touchKey === lastScanTouchKey) return;
lastScanTouchKey = touchKey;
await touchPromo({ promoId: fromEnter, countScan: true });
return;
}
// 无新 scene 时不重复扫码计数
}
/** 登录成功后:用已缓存的活动 ID 做归因(不重复加扫码次数) */
export async function touchStoredPromoAfterLogin(): Promise<void> {
const promoId = getStoredPromoId();
if (!promoId) return;
await touchPromo({ promoId, countScan: false });
}
async function touchPromo(input: { promoId: string; countScan: boolean }): Promise<void> {
try {
await request('/promo/touch', {
method: 'POST',
data: {
promoId: input.promoId,
countScan: input.countScan,
},
});
} catch {
/* 静默失败,不阻断浏览 */
}
}
+2
View File
@@ -7,6 +7,7 @@ import CouponBadge from '../../components/CouponBadge';
import ProductCarousel from '../../components/ProductCarousel';
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
import { request, toast } from '../../lib/api';
import { capturePromoSceneAndTouchScan } from '../../lib/promo';
import { getProductImages } from '../../lib/product-images';
import { getCityCodeForCatalog, resolveUserCity } from '../../lib/user-location';
type Product = {
@@ -35,6 +36,7 @@ export default function HomePage() {
useDidShow(() => {
syncTabBarSelected(0);
void capturePromoSceneAndTouchScan();
void resolveUserCity().then((resolved) => {
setDisplayCity(resolved.displayCity);
setCityCode(getCityCodeForCatalog(resolved));
+2
View File
@@ -24,6 +24,7 @@ import {
type MiniWechatProfile,
} from '../../lib/mini-wechat-profile';
import { isLoggedIn, request, saveAuth, toast, type SessionPayload } from '../../lib/api';
import { touchStoredPromoAfterLogin } from '../../lib/promo';
const IS_WEAPP = process.env.TARO_ENV === 'weapp';
@@ -176,6 +177,7 @@ export default function LoginPage() {
refreshToken: data.refreshToken,
});
void syncMiniWechatProfile(wxInfo ?? getCachedWxProfile());
void touchStoredPromoAfterLogin();
if (!phoneValue) {
void fetchUserProfile()
.then((me) => resolveDefaultUserPhone(me))