v4.0.20版本迭代推广码增加渠道负责人

This commit is contained in:
2026-09-17 09:06:29 +08:00
parent 1867e7ea55
commit b9921b1a75
36 changed files with 12138 additions and 112 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@dukang/admin-web",
"version": "4.0.19",
"version": "4.0.20",
"private": true,
"type": "module",
"scripts": {
+93 -12
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Button, Form, Input, Modal, Select, Space, Table, Tag, Tooltip, Typography, message,
Button, Form, Input, Modal, Select, Space, Table, Tag, Tooltip, message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
@@ -9,25 +9,37 @@ import {
PROMO_CODE_STATUS_LABELS,
promoConversion,
type PromoCodeItem,
type PromoCodePartnerBrief,
type PromoCodeScene,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { request, type Paginated } from '../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = PromoCodeItem;
type PartnerOption = { id: string; companyName?: string | null; name?: string; phone?: string };
type SceneOption = { value: PromoCodeScene; label: string };
function formatPartnerLabel(p?: PromoCodePartnerBrief | PartnerOption | null) {
if (!p) return '—';
const title = p.companyName || p.name || p.id;
return p.phone ? `${title} · ${p.phone}` : title;
}
function formatPartnerNames(partners?: PromoCodePartnerBrief[] | null) {
if (!partners?.length) return '—';
return partners.map((p) => p.companyName || p.name || p.id).join('、');
}
export default function PromoCodesPage() {
const navigate = useNavigate();
const [filterForm] = Form.useForm();
const [createForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const [partners, setPartners] = useState<PartnerOption[]>([]);
const [scenes, setScenes] = useState<SceneOption[]>(
Object.entries(PROMO_CODE_SCENE_LABELS).map(([value, label]) => ({
value: value as PromoCodeScene,
@@ -37,6 +49,11 @@ export default function PromoCodesPage() {
const [createOpen, setCreateOpen] = useState(false);
const [creating, setCreating] = useState(false);
const partnerSelectOptions = useMemo(
() => partners.map((p) => ({ value: p.id, label: formatPartnerLabel(p) })),
[partners],
);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/promo-codes',
() => {
@@ -45,6 +62,8 @@ export default function PromoCodesPage() {
if (filters.code) qs.set('code', filters.code);
if (filters.status) qs.set('status', filters.status);
if (filters.scene) qs.set('scene', filters.scene);
if (filters.channelOwnerPartnerId) qs.set('channelOwnerPartnerId', filters.channelOwnerPartnerId);
if (filters.assocPartnerAccountId) qs.set('assocPartnerAccountId', filters.assocPartnerAccountId);
return qs;
},
[filters],
@@ -63,6 +82,12 @@ export default function PromoCodesPage() {
void loadScenes();
}, []);
useEffect(() => {
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setPartners(res.items ?? []))
.catch(() => setPartners([]));
}, []);
const baseColumns: ColumnsType<Row> = [
{
title: '名称',
@@ -116,8 +141,13 @@ export default function PromoCodesPage() {
},
{
title: '渠道负责人',
width: 120,
render: (_, row) => row.ownerUser?.userNo || row.ownerUser?.phone || '—',
width: 160,
render: (_, row) => formatPartnerNames(row.channelOwners),
},
{
title: '关联合伙人',
width: 140,
render: (_, row) => row.assocPartner?.companyName || row.assocPartner?.name || '—',
},
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
@@ -137,7 +167,15 @@ export default function PromoCodesPage() {
},
];
async function handleCreate(values: Record<string, string>) {
async function handleCreate(values: {
name: string;
code?: string;
scene: PromoCodeScene;
page?: string;
remark?: string;
channelOwnerPartnerIds?: string[];
assocPartnerAccountId?: string;
}) {
setCreating(true);
try {
const created = await request<PromoCodeItem>('/admin/promo-codes', {
@@ -146,7 +184,8 @@ export default function PromoCodesPage() {
name: values.name,
code: values.code?.trim() || undefined,
scene: values.scene,
ownerUserId: values.ownerUserId?.trim() || undefined,
channelOwnerPartnerIds: values.channelOwnerPartnerIds ?? [],
assocPartnerAccountId: values.assocPartnerAccountId || null,
remark: values.remark?.trim() || undefined,
page: values.page?.trim() || undefined,
}),
@@ -192,6 +231,24 @@ export default function PromoCodesPage() {
options={Object.entries(PROMO_CODE_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item name="channelOwnerPartnerId" label="渠道负责人">
<Select
allowClear
showSearch
optionFilterProp="label"
style={{ minWidth: 200 }}
options={partnerSelectOptions}
/>
</Form.Item>
<Form.Item name="assocPartnerAccountId" label="关联合伙人">
<Select
allowClear
showSearch
optionFilterProp="label"
style={{ minWidth: 200 }}
options={partnerSelectOptions}
/>
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form>
@@ -235,8 +292,32 @@ export default function PromoCodesPage() {
>
<Input placeholder="pages/home/index" />
</Form.Item>
<Form.Item name="ownerUserId" label="关联用户 ID(选填)">
<Input placeholder="渠道负责人,填写用户数据库 ID" />
<Form.Item
name="channelOwnerPartnerIds"
label="渠道负责人"
extra="可空、可多选。被指定的主合伙人可在 H5 查看扫码/归因/订单数量,看不到用户或订单明细。"
>
<Select
mode="multiple"
allowClear
showSearch
optionFilterProp="label"
placeholder="选择主合伙人"
options={partnerSelectOptions}
/>
</Form.Item>
<Form.Item
name="assocPartnerAccountId"
label="关联合伙人"
extra="可空。登录用户扫该码且尚未关联任何人时,将绑定到该主合伙人;已关联他人不换绑。"
>
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="选择主合伙人"
options={partnerSelectOptions}
/>
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} placeholder="渠道说明、活动备注等" />
@@ -1,4 +1,4 @@
import { useState, type CSSProperties } from 'react';
import { useEffect, useMemo, useState, type CSSProperties } from 'react';
import { useNavigate, useOutletContext } from 'react-router-dom';
import { QuestionCircleOutlined } from '@ant-design/icons';
import {
@@ -23,8 +23,8 @@ import {
PROMO_CODE_STATUS_LABELS,
promoConversion,
} from '@dukang/shared-types';
import { request } from '../../lib/api';
import { fmtTime } from '../../lib/constants';
import { request, type Paginated } from '../../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../../lib/constants';
import type { PromoCodeDetailContext } from './PromoCodeDetailLayout';
import PromoCodeMetricsPanel from './PromoCodeMetricsPanel';
@@ -51,6 +51,19 @@ const ORDER_HINT = '下单时绑定当时的归因推广码,仅统计已完成
const CONVERSION_HINT =
'已完成订单数 ÷ 扫码进入次数(人次)。同一人多次扫码会放大分母,未登录扫码也计入。';
type PartnerOption = { id: string; companyName?: string | null; name?: string | null; phone?: string | null };
function formatPartnerLabel(p?: { companyName?: string | null; name?: string | null; phone?: string | null; id: string } | null) {
if (!p) return '—';
const title = p.companyName || p.name || p.id;
return p.phone ? `${title} · ${p.phone}` : title;
}
function formatPartnerNames(partners?: Array<{ companyName?: string | null; name?: string | null; id: string }> | null) {
if (!partners?.length) return '—';
return partners.map((p) => p.companyName || p.name || p.id).join('、');
}
function StatHint({ label, hint }: { label: string; hint: string }) {
return (
<span>
@@ -86,10 +99,30 @@ export default function PromoCodeDetailPage() {
const [editForm] = Form.useForm();
const [editOpen, setEditOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [partners, setPartners] = useState<PartnerOption[]>([]);
const stats = detail.stats;
async function handleEdit(values: Record<string, string>) {
const partnerSelectOptions = useMemo(() => {
const map = new Map(partners.map((p) => [p.id, p]));
for (const p of detail.channelOwners ?? []) map.set(p.id, p);
if (detail.assocPartner) map.set(detail.assocPartner.id, detail.assocPartner);
return [...map.values()].map((p) => ({ value: p.id, label: formatPartnerLabel(p) }));
}, [partners, detail.channelOwners, detail.assocPartner]);
useEffect(() => {
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setPartners(res.items ?? []))
.catch(() => setPartners([]));
}, []);
async function handleEdit(values: {
name: string;
scene: string;
remark?: string;
channelOwnerPartnerIds?: string[];
assocPartnerAccountId?: string;
}) {
setSaving(true);
try {
await request(`/admin/promo-codes/${detail.id}`, {
@@ -97,7 +130,8 @@ export default function PromoCodeDetailPage() {
body: JSON.stringify({
name: values.name,
scene: values.scene,
ownerUserId: values.ownerUserId?.trim() || null,
channelOwnerPartnerIds: values.channelOwnerPartnerIds ?? [],
assocPartnerAccountId: values.assocPartnerAccountId || null,
remark: values.remark?.trim() || null,
}),
});
@@ -128,7 +162,8 @@ export default function PromoCodeDetailPage() {
name: detail.name,
scene: detail.scene,
remark: detail.remark,
ownerUserId: detail.ownerUser?.id,
channelOwnerPartnerIds: (detail.channelOwners ?? []).map((p) => p.id),
assocPartnerAccountId: detail.assocPartner?.id,
});
setEditOpen(true);
}}
@@ -194,7 +229,12 @@ export default function PromoCodeDetailPage() {
</Descriptions.Item>
<Descriptions.Item label="渠道负责人">
<span style={{ whiteSpace: 'nowrap' }}>
{detail.ownerUser?.userNo || detail.ownerUser?.phone || '—'}
{formatPartnerNames(detail.channelOwners)}
</span>
</Descriptions.Item>
<Descriptions.Item label="关联合伙人">
<span style={{ whiteSpace: 'nowrap' }}>
{formatPartnerLabel(detail.assocPartner)}
</span>
</Descriptions.Item>
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
@@ -309,8 +349,32 @@ export default function PromoCodeDetailPage() {
options={Object.entries(PROMO_CODE_SCENE_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item name="ownerUserId" label="关联用户 ID">
<Input placeholder="留空表示解除关联" />
<Form.Item
name="channelOwnerPartnerIds"
label="渠道负责人"
extra="可空、可多选。被指定的主合伙人可在 H5 查看扫码/归因/订单数量。"
>
<Select
mode="multiple"
allowClear
showSearch
optionFilterProp="label"
placeholder="选择主合伙人"
options={partnerSelectOptions}
/>
</Form.Item>
<Form.Item
name="assocPartnerAccountId"
label="关联合伙人"
extra="可空。扫该码且尚未关联的用户将绑定该合伙人;已关联他人不换绑,不回刷历史。"
>
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="选择主合伙人"
options={partnerSelectOptions}
/>
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} />
+1 -1
View File
@@ -2,7 +2,7 @@ import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
const apiTarget = process.env.VITE_API_TARGET ?? 'http://localhost:3010';
const apiTarget = process.env.VITE_API_TARGET ?? 'http://127.0.0.1:3010';
export default defineConfig({
plugins: [react()],