feat: 技术支持工单/企微权限/开发版本管理/消息推送等迭代
This commit is contained in:
@@ -1,108 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, Outlet, useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { Breadcrumb, Button, Space, Spin, Tabs, Tag, Typography } from 'antd';
|
||||
import { ArrowLeftOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
PROMO_CODE_STATUS_LABELS,
|
||||
type PromoCodeItem,
|
||||
type PromoCodeStats,
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../../lib/api';
|
||||
|
||||
export type PromoCodeDetailContext = {
|
||||
detail: PromoCodeItem & { stats?: PromoCodeStats };
|
||||
reload: () => Promise<void>;
|
||||
};
|
||||
|
||||
export default function PromoCodeDetailLayout() {
|
||||
const { id = '' } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [detail, setDetail] = useState<(PromoCodeItem & { stats?: PromoCodeStats }) | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
async function loadDetail() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await request<PromoCodeItem & { stats?: PromoCodeStats }>(`/admin/promo-codes/${id}`);
|
||||
setDetail(res);
|
||||
} catch {
|
||||
setDetail(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadDetail();
|
||||
}, [id]);
|
||||
|
||||
const tabKey = location.pathname.endsWith('/users') ? 'users' : 'overview';
|
||||
|
||||
if (loading) {
|
||||
return <Spin style={{ display: 'block', margin: '80px auto' }} />;
|
||||
}
|
||||
|
||||
if (!detail) {
|
||||
return (
|
||||
<div>
|
||||
<Typography.Text type="danger">推广码不存在或加载失败</Typography.Text>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Button onClick={() => navigate('/promo-codes')}>返回列表</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumb
|
||||
style={{ marginBottom: 12 }}
|
||||
items={[
|
||||
{ title: <Link to="/promo-codes">推广码管理</Link> },
|
||||
{ title: detail.name },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16 }}>
|
||||
<Space direction="vertical" size={4}>
|
||||
<Space align="center">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/promo-codes')}
|
||||
style={{ marginLeft: -8 }}
|
||||
/>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
{detail.name}
|
||||
</Typography.Title>
|
||||
<Tag color={detail.status === 'ACTIVE' ? 'green' : 'default'}>
|
||||
{PROMO_CODE_STATUS_LABELS[detail.status] || detail.status}
|
||||
</Tag>
|
||||
</Space>
|
||||
<Typography.Text type="secondary" copyable={{ text: detail.code }}>
|
||||
码值:{detail.code}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
activeKey={tabKey}
|
||||
onChange={(key) => {
|
||||
if (key === 'users') navigate(`/promo-codes/${id}/users`);
|
||||
else navigate(`/promo-codes/${id}`);
|
||||
}}
|
||||
items={[
|
||||
{ key: 'overview', label: '概览' },
|
||||
{
|
||||
key: 'users',
|
||||
label: `关联用户${detail.stats?.sourceMarkedCount != null ? ` (${detail.stats.sourceMarkedCount})` : ''}`,
|
||||
},
|
||||
]}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<Outlet context={{ detail, reload: loadDetail } satisfies PromoCodeDetailContext} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
import { useState, type CSSProperties } from 'react';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import {
|
||||
PROMO_CODE_SCENE_LABELS,
|
||||
PROMO_CODE_STATUS_LABELS,
|
||||
promoConversion,
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../../lib/api';
|
||||
import { fmtTime } from '../../lib/constants';
|
||||
import type { PromoCodeDetailContext } from './PromoCodeDetailLayout';
|
||||
|
||||
const descLabelStyle: CSSProperties = {
|
||||
whiteSpace: 'nowrap',
|
||||
width: 108,
|
||||
};
|
||||
|
||||
const descContentStyle: CSSProperties = {
|
||||
wordBreak: 'break-all',
|
||||
};
|
||||
|
||||
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 PromoCodeDetailPage() {
|
||||
const { detail, reload } = useOutletContext<PromoCodeDetailContext>();
|
||||
const [editForm] = Form.useForm();
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const stats = detail.stats;
|
||||
|
||||
async function handleEdit(values: Record<string, string>) {
|
||||
setSaving(true);
|
||||
try {
|
||||
await request(`/admin/promo-codes/${detail.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
name: values.name,
|
||||
scene: values.scene,
|
||||
ownerUserId: values.ownerUserId?.trim() || null,
|
||||
remark: values.remark?.trim() || null,
|
||||
}),
|
||||
});
|
||||
message.success('已保存');
|
||||
setEditOpen(false);
|
||||
await reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row gutter={[16, 16]} align="top">
|
||||
<Col flex="1 1 480px" style={{ minWidth: 0 }}>
|
||||
<Card
|
||||
title="基础信息"
|
||||
size="small"
|
||||
styles={{ body: { paddingTop: 12 } }}
|
||||
extra={(
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
editForm.setFieldsValue({
|
||||
name: detail.name,
|
||||
scene: detail.scene,
|
||||
remark: detail.remark,
|
||||
ownerUserId: detail.ownerUser?.id,
|
||||
});
|
||||
setEditOpen(true);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={detail.status === 'ACTIVE' ? '确认关闭此推广码?关闭后不可再归因' : '确认重新启用?'}
|
||||
onConfirm={async () => {
|
||||
await request(`/admin/promo-codes/${detail.id}/status`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
status: detail.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE',
|
||||
}),
|
||||
});
|
||||
message.success('状态已更新');
|
||||
await reload();
|
||||
}}
|
||||
>
|
||||
<Button size="small" danger={detail.status === 'ACTIVE'}>
|
||||
{detail.status === 'ACTIVE' ? '关闭' : '启用'}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)}
|
||||
>
|
||||
<Descriptions
|
||||
column={2}
|
||||
bordered
|
||||
size="small"
|
||||
layout="horizontal"
|
||||
labelStyle={descLabelStyle}
|
||||
contentStyle={descContentStyle}
|
||||
styles={{
|
||||
label: descLabelStyle,
|
||||
content: descContentStyle,
|
||||
}}
|
||||
>
|
||||
<Descriptions.Item label="名称">{detail.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="码值">{detail.code}</Descriptions.Item>
|
||||
<Descriptions.Item label="场景">
|
||||
{PROMO_CODE_SCENE_LABELS[detail.scene] || detail.scene}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
{PROMO_CODE_STATUS_LABELS[detail.status] || detail.status}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="活动 ID">
|
||||
<Typography.Text copyable={{ text: String(detail.id) }} style={{ whiteSpace: 'nowrap' }}>
|
||||
{detail.id}
|
||||
</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="二维码 ID">
|
||||
<Typography.Text copyable={{ text: detail.qrcodeId }} style={{ whiteSpace: 'nowrap' }}>
|
||||
{detail.qrcodeId}
|
||||
</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="小程序码 OSS" span={2}>
|
||||
{detail.qrcodeUrl ? (
|
||||
<Typography.Text copyable={{ text: detail.qrcodeUrl }} ellipsis style={{ maxWidth: '100%' }}>
|
||||
{detail.qrcodeUrl}
|
||||
</Typography.Text>
|
||||
) : '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="渠道负责人">
|
||||
<span style={{ whiteSpace: 'nowrap' }}>
|
||||
{detail.ownerUser?.userNo || detail.ownerUser?.phone || '—'}
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">
|
||||
<span style={{ whiteSpace: 'nowrap' }}>{fmtTime(detail.createdAt)}</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="更新时间">
|
||||
<span style={{ whiteSpace: 'nowrap' }}>{fmtTime(detail.updatedAt)}</span>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col flex="0 0 220px">
|
||||
<Card title="小程序码" size="small" styles={{ body: { textAlign: 'center', padding: 12 } }}>
|
||||
{detail.qrcodeUrl ? (
|
||||
<>
|
||||
<img
|
||||
src={detail.qrcodeUrl}
|
||||
alt="推广小程序码"
|
||||
style={{ width: 168, height: 168, display: 'block', margin: '0 auto 8px' }}
|
||||
/>
|
||||
<Typography.Paragraph
|
||||
type="secondary"
|
||||
style={{ marginBottom: 8, fontSize: 12, whiteSpace: 'nowrap' }}
|
||||
>
|
||||
scene={detail.id}
|
||||
</Typography.Paragraph>
|
||||
<Button
|
||||
block
|
||||
size="small"
|
||||
onClick={() => void downloadQrcode(detail.qrcodeUrl!, `${detail.code}-wxacode.png`)}
|
||||
>
|
||||
下载小程序码
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Typography.Text type="secondary">暂无小程序码</Typography.Text>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16} style={{ marginTop: 16 }}>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<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}>
|
||||
<Card size="small">
|
||||
<Statistic title="订单数" value={stats?.orderCount ?? detail.orderCount} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="转化率"
|
||||
value={promoConversion(stats?.scanCount ?? detail.scanCount, stats?.orderCount ?? detail.orderCount)}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Modal
|
||||
title="编辑推广码"
|
||||
open={editOpen}
|
||||
onCancel={() => setEditOpen(false)}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={editForm} layout="vertical" onFinish={handleEdit}>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="scene" label="场景" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={Object.entries(PROMO_CODE_SCENE_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="ownerUserId" label="关联用户 ID">
|
||||
<Input placeholder="留空表示解除关联" />
|
||||
</Form.Item>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={saving} block>
|
||||
保存
|
||||
</Button>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { useNavigate, useOutletContext, useParams } from 'react-router-dom';
|
||||
import { Button, Space, Table, Tag } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
USER_SOURCE_TYPE_LABELS,
|
||||
type PromoCodeAttributedUser,
|
||||
type UserSourceType,
|
||||
} from '@dukang/shared-types';
|
||||
import { fmtTime } from '../../lib/constants';
|
||||
import { useAdminList } from '../../lib/useAdminList';
|
||||
import type { PromoCodeDetailContext } from './PromoCodeDetailLayout';
|
||||
|
||||
export default function PromoCodeUsersPage() {
|
||||
const { id = '' } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { detail } = useOutletContext<PromoCodeDetailContext>();
|
||||
|
||||
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<PromoCodeAttributedUser>(
|
||||
`/admin/promo-codes/${id}/users`,
|
||||
() => new URLSearchParams(),
|
||||
[id],
|
||||
);
|
||||
|
||||
const columns: ColumnsType<PromoCodeAttributedUser> = [
|
||||
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
|
||||
{ title: '昵称', dataIndex: 'nickname', width: 100, render: (v) => v || '—' },
|
||||
{ title: '手机', dataIndex: 'phone', width: 120, render: (v) => v || '—' },
|
||||
{
|
||||
title: '验手机',
|
||||
dataIndex: 'phoneVerifiedAt',
|
||||
width: 90,
|
||||
render: (v) => (v ? <Tag color="green">已验证</Tag> : <Tag color="orange">访客</Tag>),
|
||||
},
|
||||
{
|
||||
title: '来源类型',
|
||||
dataIndex: 'sourceType',
|
||||
width: 100,
|
||||
render: (v: string) => (
|
||||
<Tag color={v === 'PROMO_CODE' ? 'blue' : 'default'}>
|
||||
{USER_SOURCE_TYPE_LABELS[v as UserSourceType] || v}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '来源 ID',
|
||||
dataIndex: 'sourceRefId',
|
||||
width: 100,
|
||||
render: (v) => (v === detail.id ? <Tag color="blue">本码</Tag> : v || '—'),
|
||||
},
|
||||
{
|
||||
title: '首次触达',
|
||||
dataIndex: 'firstTouchAt',
|
||||
width: 160,
|
||||
render: (v) => (v ? fmtTime(v) : '—'),
|
||||
},
|
||||
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
|
||||
{ title: '注册时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 100,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Space size="small">
|
||||
<Button type="link" size="small" onClick={() => navigate('/users', { state: { openUserId: row.id } })}>
|
||||
查看用户
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
rowKey="id"
|
||||
className="admin-table-nowrap"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user