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()],
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@dukang/h5-partner",
"version": "4.0.19",
"version": "4.0.20",
"private": true,
"type": "module",
"scripts": {
+2
View File
@@ -26,6 +26,7 @@ import UsersManagePage from './pages/UsersManagePage';
import AssocOrdersPage from './pages/AssocOrdersPage';
import CommissionOrdersPage from './pages/CommissionOrdersPage';
import ActivityPostersPage from './pages/ActivityPostersPage';
import PromoCodesPage from './pages/PromoCodesPage';
import BankAccountPage from './pages/BankAccountPage';
function PrimaryRoutes() {
@@ -45,6 +46,7 @@ function PrimaryRoutes() {
<Route path="/center/staff/new" element={<StaffCreatePage />} />
<Route path="/center/bank" element={<BankAccountPage />} />
<Route path="/center/activity-posters" element={<ActivityPostersPage />} />
<Route path="/center/promo-codes" element={<PromoCodesPage />} />
<Route path="/center/assoc" element={<Navigate to="/users" replace />} />
<Route path="/center/assoc/users" element={<Navigate to="/users" replace />} />
<Route path="/users/orders" element={<AssocOrdersPage />} />
+9
View File
@@ -258,6 +258,15 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</Link>
<Link to="/center/promo-codes" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon">
<span className="material-symbols-outlined">qr_code_2</span>
</div>
<span className="body-md" style={{ fontSize: 16 }}>广</span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</Link>
<Link to="/users" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon">
@@ -0,0 +1,77 @@
import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { PROMO_CODE_STATUS_LABELS, type PartnerPromoCodeItem } from '@dukang/shared-types';
import { request } from '../lib/api';
import { toastError } from '../lib/toast';
import { usePartnerPageView } from '../lib/usePageView';
type ListRes = { items: PartnerPromoCodeItem[] };
export default function PromoCodesPage() {
usePartnerPageView('partner_promo_codes_view');
const navigate = useNavigate();
const [items, setItems] = useState<PartnerPromoCodeItem[]>([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await request<ListRes>('PARTNER_H5', '/partner/promo-codes');
setItems(res.items ?? []);
} catch (e) {
toastError(e instanceof Error ? e.message : '加载失败');
setItems([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
document.title = '推广码数据';
}, []);
useEffect(() => {
void load();
}, [load]);
return (
<PullToRefresh onRefresh={load}>
<PageHeader title="推广码数据" onBack={() => navigate(-1)} />
<div style={{ padding: '0 20px 24px' }}>
<p className="label-md text-muted" style={{ margin: '12px 0' }}>
{loading ? '加载中…' : `仅展示扫码人数、归因人数、订单数量`}
</p>
{!loading && items.length === 0 && <div className="empty">广</div>}
{items.map((row) => (
<div key={row.id} className="partner-store-card" style={{ marginBottom: 12 }}>
<div className="partner-store-card-header">
<div>
<p className="body-md">{row.name}</p>
<p className="label-md text-muted">
{row.code}
{row.status !== 'ACTIVE' ? ` · ${PROMO_CODE_STATUS_LABELS[row.status] || row.status}` : ''}
</p>
</div>
</div>
<div style={{ display: 'flex', gap: 16, marginTop: 8 }}>
<div>
<p className="label-md text-muted"></p>
<p className="body-md">{row.scanCount}</p>
</div>
<div>
<p className="label-md text-muted"></p>
<p className="body-md">{row.attributionCount}</p>
</div>
<div>
<p className="label-md text-muted"></p>
<p className="body-md">{row.orderCount}</p>
</div>
</div>
</div>
))}
</div>
</PullToRefresh>
);
}
+1 -1
View File
@@ -16,7 +16,7 @@ export default defineConfig({
host: true,
port: 5175,
proxy: {
'/api': process.env.VITE_API_TARGET ?? 'http://localhost:3010',
'/api': process.env.VITE_API_TARGET ?? 'http://127.0.0.1:3010',
},
},
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@dukang/h5-shop",
"version": "4.0.19",
"version": "4.0.20",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -14,6 +14,6 @@ export default defineConfig({
server: {
host: true,
port: 5174,
proxy: { '/api': process.env.VITE_API_TARGET ?? 'http://localhost:3010' },
proxy: { '/api': process.env.VITE_API_TARGET ?? 'http://127.0.0.1:3010' },
},
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@dukang/mini-user",
"version": "4.0.19",
"version": "4.0.20",
"private": true,
"description": "杜康好客 · C 端用户微信小程序(Taro)",
"scripts": {
+1 -1
View File
@@ -3,7 +3,7 @@ import { fetchClientConfig } from './pay-wechat';
/** 与 package.json version 同步(Taro defineConstants 注入),供 minClientVersion 比对 */
export const APP_VERSION =
(typeof TARO_APP_VERSION !== 'undefined' && String(TARO_APP_VERSION).trim()) || '4.0.19';
(typeof TARO_APP_VERSION !== 'undefined' && String(TARO_APP_VERSION).trim()) || '4.0.20';
export const APP_VERSION_LABEL = `v${APP_VERSION.replace(/^v/i, '')}`;