Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0181af09c5 | |||
| 9cd0c9508a | |||
| 4f363fa9e7 | |||
| 291137a15d | |||
| e2a5f21f9d | |||
| 3ef4432b2f | |||
| 33b868dfac | |||
| 5c4ad1d8db | |||
| a346fae7a4 | |||
| 37bc297d1a | |||
| bc1616c3f7 | |||
| 46a200e0ba | |||
| 429eaabe6b | |||
| 607a0224cd | |||
| 53e0db6a98 | |||
| 456b3af461 | |||
| 69f52921a7 | |||
| 7c9240fc84 | |||
| 9d49ed5e70 | |||
| d0ab838da6 | |||
| dc4295d1bd | |||
| bf501d2374 | |||
| b40eb83f27 | |||
| f5e523a425 | |||
| b21403a47c | |||
| f9a5b6e81a | |||
| 1c787ed576 | |||
| aca3df4bf2 | |||
| fa0d928980 | |||
| 6f427cb553 | |||
| a7d55b7293 | |||
| f09e00e16f | |||
| aa0e140fec | |||
| f8d8e2d6d5 | |||
| 9859abba7e | |||
| c89560e4f8 | |||
| ffed560d43 | |||
| 5199b495e0 | |||
| 82b98aa4e3 | |||
| 7dffd40f3f | |||
| 0304201e80 | |||
| dd4362c397 | |||
| 87515decb3 | |||
| 28ef916a22 | |||
| 39e38aac7e | |||
| daf534b4db | |||
| 118993a01f | |||
| 8f361981bf | |||
| 4bb6ae20cd | |||
| ad757e9ba8 | |||
| e63b57db19 | |||
| fb4432b307 | |||
| ab44515e8b | |||
| 78365d648c | |||
| e9eddbb26e | |||
| c00ca3620d | |||
| 4d76ee6d0e | |||
| 2dbe665bdc | |||
| 1bc3bec977 | |||
| 81a6e3674b |
@@ -0,0 +1,84 @@
|
||||
import { Descriptions } from 'antd';
|
||||
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
|
||||
function maskPhone(phone: string | null | undefined) {
|
||||
if (!phone || phone.length < 7) return phone ?? '—';
|
||||
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
detail: Record<string, unknown>;
|
||||
/** 列表页已脱敏展示时可设为 false,详情抽屉展示完整手机号 */
|
||||
maskUserPhone?: boolean;
|
||||
};
|
||||
|
||||
export default function RedeemRecordDetailDescriptions({ detail, maskUserPhone = false }: Props) {
|
||||
const user = detail.user as
|
||||
| { userNo?: string; nickname?: string | null; phone?: string | null }
|
||||
| undefined;
|
||||
const store = detail.store as
|
||||
| {
|
||||
name?: string;
|
||||
cityName?: string;
|
||||
address?: string;
|
||||
partnerAccount?: { companyName?: string };
|
||||
}
|
||||
| undefined;
|
||||
const coupon = detail.coupon as { couponNo?: string; order?: { orderNo?: string } } | undefined;
|
||||
const channel = detail.channel === 'PHONE' ? 'PHONE' : 'SCAN';
|
||||
|
||||
return (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="核销号">{String(detail.redeemNo ?? '—')}</Descriptions.Item>
|
||||
<Descriptions.Item label="方式">{REDEEM_CHANNEL_LABELS[channel as RedeemChannel]}</Descriptions.Item>
|
||||
<Descriptions.Item label="核销额">¥{Number(detail.amount ?? 0).toFixed(2)}</Descriptions.Item>
|
||||
<Descriptions.Item label="结算额">¥{Number(detail.settleAmount ?? 0).toFixed(2)}</Descriptions.Item>
|
||||
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt ?? ''))}</Descriptions.Item>
|
||||
<Descriptions.Item label="用户编号">{user?.userNo ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="用户昵称">{user?.nickname?.trim() || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="用户手机">
|
||||
{maskUserPhone ? maskPhone(user?.phone) : user?.phone || '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="门店">
|
||||
{store?.name ?? '—'}
|
||||
{store?.cityName ? `(${store.cityName})` : ''}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="门店地址">{store?.address ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="合伙人">{store?.partnerAccount?.companyName ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="权益券号">{coupon?.couponNo ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="关联订单">{coupon?.order?.orderNo ?? '—'}</Descriptions.Item>
|
||||
{Array.isArray(detail.allocations) && (detail.allocations as unknown[]).length > 0 ? (
|
||||
<Descriptions.Item label="券分摊">
|
||||
{(
|
||||
detail.allocations as Array<{
|
||||
couponNo?: string;
|
||||
orderNo?: string | null;
|
||||
amount?: number;
|
||||
}>
|
||||
)
|
||||
.map((a, idx) => {
|
||||
const role = idx === 0 ? '主' : '次';
|
||||
return `${role} ${a.couponNo ?? '—'}¥${Number(a.amount ?? 0).toFixed(2)}${
|
||||
a.orderNo ? `(订单 ${a.orderNo})` : ''
|
||||
}`;
|
||||
})
|
||||
.join(';')}
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
{detail.payout ? (
|
||||
<Descriptions.Item label="门店结算单">
|
||||
¥{Number((detail.payout as { settleAmount?: number }).settleAmount ?? 0).toFixed(2)}
|
||||
{' / '}
|
||||
{String((detail.payout as { status?: string }).status ?? '—')}
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
{detail.rating ? (
|
||||
<Descriptions.Item label="评价">
|
||||
服务 {(detail.rating as { serviceScore?: number }).serviceScore ?? '—'} 分 / 环境{' '}
|
||||
{(detail.rating as { envScore?: number }).envScore ?? '—'} 分
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
</Descriptions>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import type { ColumnsType } from 'antd/es/table';
|
||||
import type { AdminBenefitGrantRequest } from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { COUPON_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
@@ -242,7 +243,8 @@ export default function BenefitCouponsPage() {
|
||||
|
||||
<Drawer
|
||||
title="权益券详情"
|
||||
width={720}
|
||||
width={980}
|
||||
styles={{ body: { paddingBottom: 24 } }}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={
|
||||
@@ -310,14 +312,15 @@ export default function BenefitCouponsPage() {
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
tableLayout="fixed"
|
||||
locale={{ emptyText: '暂无关联核销单' }}
|
||||
dataSource={detail.redeemRecords ?? []}
|
||||
columns={[
|
||||
{ title: '核销号', dataIndex: 'redeemNo', width: 160, ellipsis: true },
|
||||
{ title: '核销号', dataIndex: 'redeemNo', ellipsis: true },
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
width: 70,
|
||||
width: 72,
|
||||
render: (v: string | undefined) =>
|
||||
v === 'SECONDARY' ? <Tag color="orange">次券</Tag> : <Tag color="blue">主券</Tag>,
|
||||
},
|
||||
@@ -331,31 +334,31 @@ export default function BenefitCouponsPage() {
|
||||
{
|
||||
title: '本券分摊',
|
||||
dataIndex: 'couponAmount',
|
||||
width: 95,
|
||||
width: 96,
|
||||
render: (v: number | undefined, row: CouponRedeemRecord) =>
|
||||
`¥${Number(v ?? row.amount).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '核销总额',
|
||||
dataIndex: 'amount',
|
||||
width: 90,
|
||||
width: 96,
|
||||
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '结算额',
|
||||
dataIndex: 'settleAmount',
|
||||
width: 90,
|
||||
width: 96,
|
||||
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 150,
|
||||
width: 148,
|
||||
render: (v: string) => fmtTime(v),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 70,
|
||||
width: 64,
|
||||
render: (_: unknown, row: CouponRedeemRecord) => (
|
||||
<Button type="link" size="small" onClick={() => void openRedeemDetail(row.id)}>
|
||||
详情
|
||||
@@ -374,7 +377,7 @@ export default function BenefitCouponsPage() {
|
||||
|
||||
<Drawer
|
||||
title="核销单详情"
|
||||
width={520}
|
||||
width={640}
|
||||
open={redeemDrawerOpen}
|
||||
onClose={() => {
|
||||
setRedeemDrawerOpen(false);
|
||||
@@ -385,79 +388,7 @@ export default function BenefitCouponsPage() {
|
||||
{redeemDetailLoading ? (
|
||||
<Typography.Text type="secondary">加载中…</Typography.Text>
|
||||
) : redeemDetail ? (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="核销号">{String(redeemDetail.redeemNo ?? '—')}</Descriptions.Item>
|
||||
<Descriptions.Item label="核销额">
|
||||
¥{Number(redeemDetail.amount ?? 0).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="结算额">
|
||||
¥{Number(redeemDetail.settleAmount ?? 0).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="时间">{fmtTime(String(redeemDetail.createdAt ?? ''))}</Descriptions.Item>
|
||||
<Descriptions.Item label="用户">
|
||||
{String((redeemDetail.user as { userNo?: string } | undefined)?.userNo ?? '—')}
|
||||
{(redeemDetail.user as { phone?: string | null } | undefined)?.phone
|
||||
? ` / ${(redeemDetail.user as { phone?: string | null }).phone}`
|
||||
: ''}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="门店">
|
||||
{String((redeemDetail.store as { name?: string } | undefined)?.name ?? '—')}
|
||||
{(redeemDetail.store as { cityName?: string } | undefined)?.cityName
|
||||
? `(${(redeemDetail.store as { cityName?: string }).cityName})`
|
||||
: ''}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="门店地址">
|
||||
{String((redeemDetail.store as { address?: string } | undefined)?.address ?? '—')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="合伙人">
|
||||
{String(
|
||||
(redeemDetail.store as { partnerAccount?: { companyName?: string } } | undefined)
|
||||
?.partnerAccount?.companyName ?? '—',
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="权益券号">
|
||||
{String((redeemDetail.coupon as { couponNo?: string } | undefined)?.couponNo ?? '—')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="关联订单">
|
||||
{String(
|
||||
(redeemDetail.coupon as { order?: { orderNo?: string } } | undefined)?.order?.orderNo ??
|
||||
'—',
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
{Array.isArray(redeemDetail.allocations) &&
|
||||
(redeemDetail.allocations as unknown[]).length > 0 ? (
|
||||
<Descriptions.Item label="券分摊">
|
||||
{(
|
||||
redeemDetail.allocations as Array<{
|
||||
couponNo?: string;
|
||||
orderNo?: string | null;
|
||||
amount?: number;
|
||||
sortOrder?: number;
|
||||
}>
|
||||
)
|
||||
.map((a, idx) => {
|
||||
const role = idx === 0 ? '主' : '次';
|
||||
return `${role} ${a.couponNo ?? '—'}¥${Number(a.amount ?? 0).toFixed(2)}${
|
||||
a.orderNo ? `(订单 ${a.orderNo})` : ''
|
||||
}`;
|
||||
})
|
||||
.join(';')}
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
{redeemDetail.payout ? (
|
||||
<Descriptions.Item label="门店结算单">
|
||||
¥{Number((redeemDetail.payout as { settleAmount?: number }).settleAmount ?? 0).toFixed(2)}
|
||||
{' / '}
|
||||
{String((redeemDetail.payout as { status?: string }).status ?? '—')}
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
{redeemDetail.rating ? (
|
||||
<Descriptions.Item label="评价">
|
||||
服务 {(redeemDetail.rating as { serviceScore?: number }).serviceScore ?? '—'} 分 / 环境{' '}
|
||||
{(redeemDetail.rating as { envScore?: number }).envScore ?? '—'} 分
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
</Descriptions>
|
||||
<RedeemRecordDetailDescriptions detail={redeemDetail} />
|
||||
) : null}
|
||||
</Drawer>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Descriptions, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
|
||||
import { Button, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
|
||||
import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
@@ -39,6 +40,17 @@ export default function RedeemRecordsPage() {
|
||||
);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
async function openDetail(id: string) {
|
||||
setDetailLoading(true);
|
||||
setDrawerOpen(true);
|
||||
try {
|
||||
setDetail(await request(`/admin/redeem-records/${id}`));
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '核销号', dataIndex: 'redeemNo', width: 180 },
|
||||
@@ -77,14 +89,7 @@ export default function RedeemRecordsPage() {
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, row) => (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
setDetail(await request(`/admin/redeem-records/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}
|
||||
>
|
||||
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
@@ -142,35 +147,21 @@ export default function RedeemRecordsPage() {
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Drawer title="核销详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||
{detail && (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="核销号">{String(detail.redeemNo)}</Descriptions.Item>
|
||||
<Descriptions.Item label="方式">
|
||||
{
|
||||
REDEEM_CHANNEL_LABELS[
|
||||
(detail.channel === 'PHONE' ? 'PHONE' : 'SCAN') as RedeemChannel
|
||||
]
|
||||
}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="核销额">¥{String(detail.amount)}</Descriptions.Item>
|
||||
<Descriptions.Item label="结算额">¥{String(detail.settleAmount)}</Descriptions.Item>
|
||||
{detail.user && typeof detail.user === 'object' ? (
|
||||
<>
|
||||
<Descriptions.Item label="用户编号">
|
||||
{String((detail.user as { userNo?: string }).userNo ?? '—')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="用户昵称">
|
||||
{String((detail.user as { nickname?: string | null }).nickname ?? '—')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="用户手机">
|
||||
{maskPhone((detail.user as { phone?: string | null }).phone)}
|
||||
</Descriptions.Item>
|
||||
</>
|
||||
) : null}
|
||||
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
<Drawer
|
||||
title="核销详情"
|
||||
width={640}
|
||||
open={drawerOpen}
|
||||
onClose={() => {
|
||||
setDrawerOpen(false);
|
||||
setDetail(null);
|
||||
}}
|
||||
destroyOnClose
|
||||
>
|
||||
{detailLoading ? (
|
||||
<Typography.Text type="secondary">加载中…</Typography.Text>
|
||||
) : detail ? (
|
||||
<RedeemRecordDetailDescriptions detail={detail} />
|
||||
) : null}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -376,7 +376,7 @@ type StoreRow = {
|
||||
visibilityWhitelistEnabled?: boolean;
|
||||
visibilityPhones?: string[];
|
||||
cityRef?: { name: string; code: string };
|
||||
partner?: { companyName: string };
|
||||
partner?: { id?: string; companyName?: string | null; name?: string | null; phone?: string | null };
|
||||
account?: {
|
||||
phone: string;
|
||||
name: string;
|
||||
@@ -394,13 +394,14 @@ type StoreRow = {
|
||||
category?: { id: string; name: string; parentId?: string | null } | null;
|
||||
};
|
||||
|
||||
type PartnerOption = { id: string; companyName: string; name?: string };
|
||||
type PartnerOption = { id: string; companyName?: string | null; name?: string | null; phone?: string | null };
|
||||
|
||||
function partnerOptionLabel(p: PartnerOption): string {
|
||||
const company = (p.companyName || '').trim();
|
||||
const person = (p.name || '').trim();
|
||||
const phone = (p.phone || '').trim();
|
||||
if (company && person) return `${company}-${person}`;
|
||||
return company || person || p.id;
|
||||
return company || person || phone || p.id;
|
||||
}
|
||||
type CityOption = {
|
||||
id: string;
|
||||
@@ -867,7 +868,13 @@ export default function StoresPage() {
|
||||
);
|
||||
},
|
||||
},
|
||||
{ title: '开城合伙人', dataIndex: ['partner', 'companyName'], width: 120 },
|
||||
{
|
||||
title: '开城合伙人',
|
||||
dataIndex: 'partner',
|
||||
width: 140,
|
||||
render: (partner: StoreRow['partner']) =>
|
||||
partner ? partnerOptionLabel({ id: partner.id ?? '', ...partner }) : '—',
|
||||
},
|
||||
{
|
||||
title: '可见',
|
||||
dataIndex: 'visibilityWhitelistEnabled',
|
||||
|
||||
@@ -56,6 +56,7 @@ const STATUS_COLOR: Record<SupportTicketStatusDto, string> = {
|
||||
DEVELOPING: 'blue',
|
||||
TESTING: 'purple',
|
||||
PASSED: 'green',
|
||||
PUBLISHED: 'cyan',
|
||||
};
|
||||
|
||||
const TYPE_OPTIONS = (Object.keys(SUPPORT_TICKET_TYPE_LABELS) as SupportTicketTypeDto[]).map(
|
||||
@@ -482,8 +483,15 @@ export default function SupportTicketsPage() {
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: (s: SupportTicketStatusDto) => (
|
||||
<Tag color={STATUS_COLOR[s]}>{SUPPORT_TICKET_STATUS_LABELS[s] ?? s}</Tag>
|
||||
render: (s: SupportTicketStatusDto, row) => (
|
||||
<Space size={4} direction="vertical" style={{ lineHeight: 1.2 }}>
|
||||
<Tag color={STATUS_COLOR[s]}>{SUPPORT_TICKET_STATUS_LABELS[s] ?? s}</Tag>
|
||||
{row.releasedVersionNo ? (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
|
||||
{row.releasedVersionNo}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: '标题', dataIndex: 'title', ellipsis: true },
|
||||
@@ -666,6 +674,11 @@ export default function SupportTicketsPage() {
|
||||
{detail.status === 'PASSED' && (
|
||||
<Typography.Text type="success">此工单已测试通过</Typography.Text>
|
||||
)}
|
||||
{detail.status === 'PUBLISHED' && (
|
||||
<Typography.Text type="success">
|
||||
已发布{detail.releasedVersionNo ? ` · ${detail.releasedVersionNo}` : ''}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Space>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
创建于 {fmtTime(detail.createdAt)}
|
||||
@@ -689,6 +702,7 @@ export default function SupportTicketsPage() {
|
||||
{ key: 'DEVELOPING' as SupportTicketStatusDto, label: '开发中', time: detail.reviewedAt },
|
||||
{ key: 'TESTING' as SupportTicketStatusDto, label: '测试中', time: null },
|
||||
{ key: 'PASSED' as SupportTicketStatusDto, label: '已通过', time: detail.completedAt },
|
||||
{ key: 'PUBLISHED' as SupportTicketStatusDto, label: '已发布', time: detail.publishedAt },
|
||||
]
|
||||
: [{ key: 'REJECTED' as SupportTicketStatusDto, label: '已驳回', time: detail.reviewedAt }]),
|
||||
];
|
||||
|
||||
@@ -10,11 +10,18 @@ import {
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { TICKET_TYPE_LABELS, type TicketTypeDto } from '@dukang/shared-types';
|
||||
import {
|
||||
TICKET_STATUS_LABELS,
|
||||
TICKET_TYPE_LABELS,
|
||||
ticketStatusLabel,
|
||||
type TicketStatusDto,
|
||||
type TicketTypeDto,
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
@@ -31,6 +38,21 @@ type Row = {
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
const STATUS_COLOR: Partial<Record<TicketStatusDto, string>> = {
|
||||
PENDING: 'orange',
|
||||
OPEN: 'blue',
|
||||
COLLABORATING: 'cyan',
|
||||
RESOLVED: 'green',
|
||||
REJECTED: 'red',
|
||||
COMPLETED: 'green',
|
||||
CLOSED: 'default',
|
||||
};
|
||||
|
||||
const STATUS_OPTIONS = (Object.keys(TICKET_STATUS_LABELS) as TicketStatusDto[]).map((value) => ({
|
||||
value,
|
||||
label: TICKET_STATUS_LABELS[value],
|
||||
}));
|
||||
|
||||
export default function TicketsPage() {
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
@@ -101,7 +123,14 @@ export default function TicketsPage() {
|
||||
width: 110,
|
||||
render: (t: TicketTypeDto) => TICKET_TYPE_LABELS[t] ?? t,
|
||||
},
|
||||
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: (s: string) => (
|
||||
<Tag color={STATUS_COLOR[s as TicketStatusDto] ?? 'default'}>{ticketStatusLabel(s)}</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '关联', width: 140, render: (_, r) => `${r.refType}#${r.refId}` },
|
||||
{ title: '备注', dataIndex: 'remark', ellipsis: true },
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
@@ -165,7 +194,7 @@ export default function TicketsPage() {
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Input allowClear placeholder="PENDING" />
|
||||
<Select allowClear style={{ width: 140 }} placeholder="全部" options={STATUS_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
筛选
|
||||
@@ -212,7 +241,11 @@ export default function TicketsPage() {
|
||||
<Descriptions.Item label="类型">
|
||||
{TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{String(detail.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={STATUS_COLOR[detail.status as TicketStatusDto] ?? 'default'}>
|
||||
{ticketStatusLabel(detail.status)}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="关联">
|
||||
{String(detail.refType)} #{String(detail.refId)}
|
||||
</Descriptions.Item>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { request } from '../../lib/api';
|
||||
import { fmtTime } from '../../lib/constants';
|
||||
import type { PromoCodeDetailContext } from './PromoCodeDetailLayout';
|
||||
import PromoCodeMetricsPanel from './PromoCodeMetricsPanel';
|
||||
|
||||
const descLabelStyle: CSSProperties = {
|
||||
whiteSpace: 'nowrap',
|
||||
@@ -243,6 +244,8 @@ export default function PromoCodeDetailPage() {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<PromoCodeMetricsPanel promoId={detail.id} />
|
||||
|
||||
<Modal
|
||||
title="编辑推广码"
|
||||
open={editOpen}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
DatePicker,
|
||||
Radio,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import type { EChartsOption } from 'echarts';
|
||||
import {
|
||||
PROMO_METRIC_EVENT_LABELS,
|
||||
type PromoMetricEventItem,
|
||||
type PromoMetricEventType,
|
||||
type PromoMetricTimelineDto,
|
||||
} from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../../lib/api';
|
||||
import { fmtTime } from '../../lib/constants';
|
||||
|
||||
type Props = {
|
||||
promoId: string;
|
||||
};
|
||||
|
||||
const SERIES = [
|
||||
{ key: 'scan', name: '扫码进入', color: '#1677ff' },
|
||||
{ key: 'attribution', name: '归因用户', color: '#52c41a' },
|
||||
{ key: 'register', name: '扫码注册', color: '#faad14' },
|
||||
{ key: 'order', name: '订单', color: '#eb2f96' },
|
||||
] as const;
|
||||
|
||||
function buildTimelineQs(promoId: string, range: [Dayjs, Dayjs], granularity: 'day' | 'hour') {
|
||||
const qs = new URLSearchParams();
|
||||
qs.set('dateFrom', range[0].format('YYYY-MM-DD'));
|
||||
qs.set('dateTo', range[1].format('YYYY-MM-DD'));
|
||||
qs.set('granularity', granularity);
|
||||
return `/admin/promo-codes/${promoId}/metrics/timeline?${qs.toString()}`;
|
||||
}
|
||||
|
||||
function buildEventsQs(
|
||||
promoId: string,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
range: [Dayjs, Dayjs],
|
||||
eventType?: PromoMetricEventType,
|
||||
) {
|
||||
const qs = new URLSearchParams();
|
||||
qs.set('page', String(page));
|
||||
qs.set('pageSize', String(pageSize));
|
||||
qs.set('dateFrom', range[0].format('YYYY-MM-DD'));
|
||||
qs.set('dateTo', range[1].format('YYYY-MM-DD'));
|
||||
if (eventType) qs.set('eventType', eventType);
|
||||
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 formatLocation(row: PromoMetricEventItem): string {
|
||||
const parts = [row.ipProvince, row.ipCity].filter(Boolean);
|
||||
if (parts.length) return parts.join(' ');
|
||||
return '—';
|
||||
}
|
||||
|
||||
export default function PromoCodeMetricsPanel({ promoId }: Props) {
|
||||
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 [eventsPage, setEventsPage] = useState(1);
|
||||
const [events, setEvents] = useState<Paginated<PromoMetricEventItem> | null>(null);
|
||||
const [eventsLoading, setEventsLoading] = useState(false);
|
||||
|
||||
const loadTimeline = useCallback(() => {
|
||||
setTimelineLoading(true);
|
||||
return request<PromoMetricTimelineDto>(buildTimelineQs(promoId, range, granularity))
|
||||
.then(setTimeline)
|
||||
.catch((e) => {
|
||||
message.error(e instanceof Error ? e.message : '加载趋势失败');
|
||||
setTimeline(null);
|
||||
})
|
||||
.finally(() => setTimelineLoading(false));
|
||||
}, [promoId, range, granularity]);
|
||||
|
||||
const loadEvents = useCallback(() => {
|
||||
setEventsLoading(true);
|
||||
return request<Paginated<PromoMetricEventItem>>(
|
||||
buildEventsQs(promoId, eventsPage, 20, range, eventType),
|
||||
)
|
||||
.then(setEvents)
|
||||
.catch((e) => {
|
||||
message.error(e instanceof Error ? e.message : '加载事件失败');
|
||||
setEvents(null);
|
||||
})
|
||||
.finally(() => setEventsLoading(false));
|
||||
}, [promoId, eventsPage, range, eventType]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadTimeline();
|
||||
}, [loadTimeline]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadEvents();
|
||||
}, [loadEvents]);
|
||||
|
||||
const chartOption = useMemo<EChartsOption>(() => {
|
||||
const buckets = timeline?.buckets ?? [];
|
||||
const xData = buckets.map((b) => b.key);
|
||||
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: SERIES.map((s) => s.name), bottom: 0 },
|
||||
grid: { left: 48, right: 24, top: 24, bottom: 48 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: xData,
|
||||
axisLabel: {
|
||||
rotate: granularity === 'hour' ? 45 : 0,
|
||||
formatter: (v: string) => (granularity === 'hour' ? v.slice(5, 16) : v.slice(5)),
|
||||
},
|
||||
},
|
||||
yAxis: { type: 'value', minInterval: 1 },
|
||||
series: SERIES.map((s) => ({
|
||||
name: s.name,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: buckets.map((b) => b[s.key]),
|
||||
itemStyle: { color: s.color },
|
||||
markPoint: {
|
||||
symbol: 'pin',
|
||||
symbolSize: 42,
|
||||
data: (() => {
|
||||
const peak = timeline?.peak[s.key];
|
||||
if (!peak || peak.count <= 0) return [];
|
||||
return [{ name: '高峰', coord: [peak.key, peak.count], value: peak.count }];
|
||||
})(),
|
||||
},
|
||||
})),
|
||||
};
|
||||
}, [timeline, granularity]);
|
||||
|
||||
const columns: ColumnsType<PromoMetricEventItem> = [
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 168,
|
||||
render: (v: string) => fmtTime(v),
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'eventType',
|
||||
width: 108,
|
||||
render: (v: PromoMetricEventType) => PROMO_METRIC_EVENT_LABELS[v],
|
||||
},
|
||||
{
|
||||
title: 'ID',
|
||||
key: 'ref',
|
||||
render: (_, row) => formatRefId(row),
|
||||
},
|
||||
{
|
||||
title: 'IP',
|
||||
dataIndex: 'clientIp',
|
||||
width: 128,
|
||||
render: (v: string | null) => v ?? '—',
|
||||
},
|
||||
{
|
||||
title: '地点',
|
||||
key: 'location',
|
||||
width: 120,
|
||||
render: (_, row) => formatLocation(row),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="数据趋势"
|
||||
style={{ marginTop: 16 }}
|
||||
extra={
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
仅统计功能上线后的新事件
|
||||
</Typography.Text>
|
||||
}
|
||||
>
|
||||
<Space wrap style={{ marginBottom: 16 }}>
|
||||
<DatePicker.RangePicker
|
||||
value={range}
|
||||
onChange={(v) => {
|
||||
if (v?.[0] && v[1]) {
|
||||
setRange([v[0], v[1]]);
|
||||
setEventsPage(1);
|
||||
}
|
||||
}}
|
||||
allowClear={false}
|
||||
/>
|
||||
<Radio.Group
|
||||
value={granularity}
|
||||
onChange={(e) => setGranularity(e.target.value as 'day' | 'hour')}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
options={[
|
||||
{ label: '按日', value: 'day' },
|
||||
{ label: '按时', value: 'hour' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<ReactECharts
|
||||
option={chartOption}
|
||||
style={{ height: 360 }}
|
||||
showLoading={timelineLoading}
|
||||
notMerge
|
||||
/>
|
||||
|
||||
<Typography.Title level={5} style={{ marginTop: 24, marginBottom: 12 }}>
|
||||
事件日志
|
||||
</Typography.Title>
|
||||
<Space wrap style={{ marginBottom: 12 }}>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="全部类型"
|
||||
style={{ width: 160 }}
|
||||
value={eventType}
|
||||
onChange={(v) => {
|
||||
setEventType(v);
|
||||
setEventsPage(1);
|
||||
}}
|
||||
options={Object.entries(PROMO_METRIC_EVENT_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}))}
|
||||
/>
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
columns={columns}
|
||||
dataSource={events?.items ?? []}
|
||||
loading={eventsLoading}
|
||||
pagination={{
|
||||
current: eventsPage,
|
||||
pageSize: events?.pageSize ?? 20,
|
||||
total: events?.total ?? 0,
|
||||
showSizeChanger: false,
|
||||
onChange: (p) => setEventsPage(p),
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -48,6 +48,19 @@ export async function canPartnerUseWechatLogin(options: {
|
||||
}
|
||||
}
|
||||
|
||||
/** 短信登录/发码前:校验手机号对应合伙人存在且未暂停 */
|
||||
export async function checkPartnerPhoneForLogin(phone: string): Promise<PartnerPhoneCheckResponse> {
|
||||
const normalized = phone.trim();
|
||||
if (!/^1\d{10}$/.test(normalized)) {
|
||||
throw new Error('请输入正确的手机号');
|
||||
}
|
||||
return request<PartnerPhoneCheckResponse>('PARTNER_H5', '/partner/auth/phone/check', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: normalized }),
|
||||
silent: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchClientConfig(): Promise<ClientRuntimeConfig> {
|
||||
return request<ClientRuntimeConfig>('PARTNER_H5', '/common/client-config');
|
||||
}
|
||||
|
||||
@@ -12,8 +12,10 @@ import {
|
||||
} from '../lib/api';
|
||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||
import { partnerHomePath } from '../lib/partnerAccess';
|
||||
import type { PartnerPhoneCheckResponse } from '@dukang/shared-types';
|
||||
import {
|
||||
bindPartnerWechatAfterSmsLogin,
|
||||
checkPartnerPhoneForLogin,
|
||||
fetchClientConfig,
|
||||
loginPartnerWithWechat,
|
||||
} from '../lib/wechat-auth';
|
||||
@@ -69,14 +71,17 @@ function formatPartnerError(e: unknown): string {
|
||||
if (text.includes('合伙人账号不存在') || text.includes('未找到合伙人账号')) {
|
||||
return '未找到合伙人账号';
|
||||
}
|
||||
if (text.includes('合伙人账号已停用')) {
|
||||
return '合伙人账号已停用';
|
||||
if (text.includes('合伙人账号已停用') || text.includes('账号已停用')) {
|
||||
return '合伙人账号已暂停,无法登录';
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function formatWechatError(e: unknown): string {
|
||||
const text = e instanceof Error ? e.message : '微信登录失败';
|
||||
if (text.includes('合伙人账号已停用') || text.includes('账号已停用')) {
|
||||
return '合伙人账号已暂停,无法登录';
|
||||
}
|
||||
if (text.includes('首次登录') || text.includes('手机验证码')) {
|
||||
return '该微信尚未绑定合伙人账号,请先使用手机验证码登录,登录后将自动关联微信';
|
||||
}
|
||||
@@ -138,11 +143,21 @@ export default function LoginPage() {
|
||||
|
||||
async function sendCode() {
|
||||
if (!ensureAgreed()) return;
|
||||
const trimmedPhone = phone.trim();
|
||||
if (!trimmedPhone) {
|
||||
setMsg('请输入手机号');
|
||||
return;
|
||||
}
|
||||
if (!/^1\d{10}$/.test(trimmedPhone)) {
|
||||
setMsg('请输入正确的手机号');
|
||||
return;
|
||||
}
|
||||
setMsg('');
|
||||
try {
|
||||
await checkPartnerPhoneForLogin(trimmedPhone);
|
||||
await request('PARTNER_H5', '/partner/auth/sms/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone, scene: 'PARTNER_LOGIN' }),
|
||||
body: JSON.stringify({ phone: trimmedPhone, scene: 'PARTNER_LOGIN' }),
|
||||
silent: true,
|
||||
});
|
||||
setMsg('验证码已发送');
|
||||
@@ -171,16 +186,21 @@ export default function LoginPage() {
|
||||
const trimmedPhone = phone.trim();
|
||||
const trimmedCode = code.trim();
|
||||
if (!trimmedPhone) {
|
||||
setMsg('phone should not be empty');
|
||||
setMsg('请输入手机号');
|
||||
return;
|
||||
}
|
||||
if (!/^1\d{10}$/.test(trimmedPhone)) {
|
||||
setMsg('请输入正确的手机号');
|
||||
return;
|
||||
}
|
||||
if (!trimmedCode) {
|
||||
setMsg('code should not be empty');
|
||||
setMsg('请输入验证码');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
await checkPartnerPhoneForLogin(trimmedPhone);
|
||||
const data = await request<PartnerSessionPayload>('PARTNER_H5', '/partner/auth/login/sms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: trimmedPhone, code: trimmedCode }),
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import {
|
||||
fetchClientConfig,
|
||||
handleShopWechatCallback,
|
||||
handleShopWechatCallbackOnce,
|
||||
handleShopWechatLoginResult,
|
||||
isShopWechatUnboundError,
|
||||
SHOP_WX_NEED_PHONE_LOGIN_MSG,
|
||||
@@ -79,7 +80,7 @@ export function StoreSessionProvider({ children }: { children: ReactNode }) {
|
||||
try {
|
||||
const config = await fetchClientConfig();
|
||||
if (isWxAuthorizeEnabled(config)) {
|
||||
const result = await handleShopWechatCallback();
|
||||
const result = await handleShopWechatCallbackOnce();
|
||||
if (result && !cancelled) {
|
||||
const session = handleShopWechatLoginResult(result);
|
||||
if (session) applySession(session);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { isIosDevice } from '@dukang/weixin-sdk';
|
||||
|
||||
/** 扫码前发起 OAuth 时标记,回跳后在首页续扫 */
|
||||
export const SHOP_PENDING_SCAN_KEY = 'shop_pending_scan';
|
||||
|
||||
export function markPendingScanAfterAuth(): void {
|
||||
try {
|
||||
sessionStorage.setItem(SHOP_PENDING_SCAN_KEY, '1');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function peekPendingScanAfterAuth(): boolean {
|
||||
try {
|
||||
return sessionStorage.getItem(SHOP_PENDING_SCAN_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearPendingScanAfterAuth(): void {
|
||||
try {
|
||||
sessionStorage.removeItem(SHOP_PENDING_SCAN_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** OAuth 回跳后延迟再调 scanQRCode(iOS JSSDK 离线校验更慢) */
|
||||
export function getPostAuthScanDelayMs(): number {
|
||||
return isIosDevice() ? 1200 : 600;
|
||||
}
|
||||
@@ -138,6 +138,20 @@ export async function handleShopWechatCallback(): Promise<WechatLoginResult | nu
|
||||
return weixinSdk.handleOAuthCallback();
|
||||
}
|
||||
|
||||
let oauthCallbackInflight: Promise<WechatLoginResult | null> | null = null;
|
||||
|
||||
/** 全局单例:避免 Context 与页面 effect 重复消费 OAuth code */
|
||||
export async function handleShopWechatCallbackOnce(): Promise<WechatLoginResult | null> {
|
||||
if (!isWechatEnv()) return null;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (!params.get('code')) return null;
|
||||
if (oauthCallbackInflight) return oauthCallbackInflight;
|
||||
oauthCallbackInflight = handleShopWechatCallback().finally(() => {
|
||||
oauthCallbackInflight = null;
|
||||
});
|
||||
return oauthCallbackInflight;
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信一键登录(已绑定微信的门店账号免验证码)。
|
||||
* 返回 session = 已登录;void = 已跳转授权页等待回调。
|
||||
|
||||
+293
-285
@@ -1,285 +1,293 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
import { request } from '../lib/api';
|
||||
import { parseRedeemTokenFromScan } from '../lib/redeem-scan';
|
||||
import {
|
||||
authorizeShopWechat,
|
||||
checkNeedsWechatAuth,
|
||||
fetchShopAccount,
|
||||
handleShopWechatCallback,
|
||||
handleShopWechatLoginResult,
|
||||
} from '../lib/wechat-auth';
|
||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
|
||||
import WechatScanAuthModal from '../components/WechatScanAuthModal';
|
||||
import { useStorePageView } from '../lib/usePageView';
|
||||
import { trackStore } from '../lib/analytics';
|
||||
|
||||
const PENDING_SCAN_KEY = 'shop_pending_scan';
|
||||
|
||||
function formatMoney(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function formatScanError(e: unknown): string {
|
||||
const msg = e instanceof Error ? e.message : '扫码失败,请重试';
|
||||
if (/invalid signature/i.test(msg)) {
|
||||
return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名,并刷新页面后重试';
|
||||
}
|
||||
if (/offline verifying|权限验证中|接口未就绪/i.test(msg)) {
|
||||
return '微信权限校验尚未完成,请等待 1~2 秒后再次点击扫码(首次绑定微信时较常见)';
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
useStorePageView('store_home_view');
|
||||
const navigate = useNavigate();
|
||||
const { applySession } = useStoreSession();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
|
||||
const [scanMsg, setScanMsg] = useState('');
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [authModalOpen, setAuthModalOpen] = useState(false);
|
||||
const [authLoading, setAuthLoading] = useState(false);
|
||||
const [authError, setAuthError] = useState('');
|
||||
|
||||
const loadDashboard = useCallback(() => {
|
||||
return request<Record<string, unknown>>('SHOP_H5', '/shop/dashboard')
|
||||
.then((d) => {
|
||||
setDash(d);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadDashboard();
|
||||
}, [loadDashboard]);
|
||||
|
||||
useEffect(() => {
|
||||
function onResume() {
|
||||
setScanning(false);
|
||||
void loadDashboard();
|
||||
}
|
||||
function onVisibility() {
|
||||
if (document.visibilityState === 'visible') onResume();
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
window.addEventListener('pageshow', onResume);
|
||||
window.addEventListener('focus', onResume);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
window.removeEventListener('pageshow', onResume);
|
||||
window.removeEventListener('focus', onResume);
|
||||
};
|
||||
}, [loadDashboard]);
|
||||
|
||||
async function runScan(opts?: { postAuthWarmup?: boolean }) {
|
||||
trackStore('store_redeem_scan_start');
|
||||
if (!isWechatEnv()) {
|
||||
setScanMsg('请在微信内打开门店端进行扫码核销');
|
||||
return;
|
||||
}
|
||||
setScanning(true);
|
||||
setScanMsg('');
|
||||
try {
|
||||
// 授权回跳后强制重签,避免沿用带 code 的旧签名态
|
||||
if (opts?.postAuthWarmup) {
|
||||
weixinSdk.reset();
|
||||
}
|
||||
await weixinSdk.init();
|
||||
const raw = await weixinSdk.scanQrCode(
|
||||
opts?.postAuthWarmup ? { postAuthWarmup: true } : undefined,
|
||||
);
|
||||
if (!raw) {
|
||||
void loadDashboard();
|
||||
return;
|
||||
}
|
||||
const token = parseRedeemTokenFromScan(raw);
|
||||
if (!token) {
|
||||
setScanMsg('无法识别核销码,请扫描用户出示的核销二维码');
|
||||
return;
|
||||
}
|
||||
navigate(`/redeem?token=${encodeURIComponent(token)}`);
|
||||
} catch (e) {
|
||||
setScanMsg(formatScanError(e));
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWechatEnv() || !searchParams.get('code')) return;
|
||||
void handleShopWechatCallback()
|
||||
.then((result) => {
|
||||
if (!result) return;
|
||||
const session = handleShopWechatLoginResult(result);
|
||||
if (session) {
|
||||
applySession(session);
|
||||
}
|
||||
setAuthModalOpen(false);
|
||||
setAuthError('');
|
||||
// 先清 OAuth 参数再扫,保证 JSSDK 签名 URL 与当前页一致
|
||||
stripOAuthParamsFromLocation();
|
||||
setSearchParams({}, { replace: true });
|
||||
const shouldScan = sessionStorage.getItem(PENDING_SCAN_KEY) === '1';
|
||||
sessionStorage.removeItem(PENDING_SCAN_KEY);
|
||||
if (shouldScan) {
|
||||
// OAuth 回跳后微信权限离线校验未完成;稍候再扫,失败可再点一次
|
||||
window.setTimeout(() => void runScan({ postAuthWarmup: true }), 400);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
setAuthError(e instanceof Error ? e.message : '微信授权失败');
|
||||
});
|
||||
}, [searchParams, applySession, setSearchParams]);
|
||||
|
||||
async function handleScan() {
|
||||
setScanMsg('');
|
||||
if (!isWechatEnv()) {
|
||||
setScanMsg('请在微信内打开门店端进行扫码核销');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const profile = await fetchShopAccount();
|
||||
if (await checkNeedsWechatAuth(profile)) {
|
||||
setAuthModalOpen(true);
|
||||
return;
|
||||
}
|
||||
await runScan();
|
||||
} catch (e) {
|
||||
setScanMsg(e instanceof Error ? e.message : '无法发起扫码');
|
||||
}
|
||||
}
|
||||
|
||||
async function startWechatAuth() {
|
||||
setAuthLoading(true);
|
||||
setAuthError('');
|
||||
try {
|
||||
sessionStorage.setItem(PENDING_SCAN_KEY, '1');
|
||||
await authorizeShopWechat();
|
||||
} catch (e) {
|
||||
sessionStorage.removeItem(PENDING_SCAN_KEY);
|
||||
setAuthError(e instanceof Error ? e.message : '微信授权失败');
|
||||
setAuthLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const store = dash?.store as Record<string, unknown> | undefined;
|
||||
const recent = (dash?.recentRecords as Array<Record<string, unknown>>) || [];
|
||||
const status = String(store?.status || '');
|
||||
const open = status === 'OPEN';
|
||||
const hoursParts: string[] = [];
|
||||
if (store?.openTime && store?.closeTime) hoursParts.push(`${store.openTime} - ${store.closeTime}`);
|
||||
if (store?.openTime2 && store?.closeTime2) hoursParts.push(`${store.openTime2} - ${store.closeTime2}`);
|
||||
const hoursText = hoursParts.length ? hoursParts.join(',') : '10:00 - 22:00';
|
||||
const statusText =
|
||||
status === 'CLOSED' ? '永久关闭' : open ? '当前正在营业中' : '当前临时闭店';
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={loadDashboard} className="shop-home-page">
|
||||
<header className="shop-home-header">
|
||||
<h1 className="app-page-title">门店管理中心</h1>
|
||||
</header>
|
||||
|
||||
<div className="shop-home-content">
|
||||
<section className="shop-home-hero">
|
||||
<div className="shop-home-hero-store">
|
||||
<span className="material-symbols-outlined shop-fill-icon">store</span>
|
||||
<h2>{String(store?.name || '门店')}</h2>
|
||||
</div>
|
||||
<div className="shop-home-stats">
|
||||
<div className="shop-home-stat">
|
||||
<p className="shop-home-stat-label">今日核销笔数</p>
|
||||
<p className="shop-home-stat-value">{Number(dash?.todayCount || 0)}</p>
|
||||
<p className="shop-home-stat-sub">
|
||||
扫码 {Number(dash?.todayScanCount || 0)} · 手机号 {Number(dash?.todayPhoneCount || 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="shop-home-stat">
|
||||
<p className="shop-home-stat-label">今日到账金额</p>
|
||||
<p className="shop-home-stat-value">
|
||||
<span style={{ fontSize: 18 }}>¥</span>
|
||||
{formatMoney(Number(dash?.todayAmount || 0))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="shop-home-scan">
|
||||
<button
|
||||
type="button"
|
||||
className="shop-home-scan-btn"
|
||||
disabled={scanning}
|
||||
onClick={() => void handleScan()}
|
||||
>
|
||||
<span className="material-symbols-outlined">qr_code_scanner</span>
|
||||
</button>
|
||||
<p className="shop-home-scan-label">{scanning ? '正在打开相机…' : '扫码核销'}</p>
|
||||
{scanMsg && <p className="shop-home-scan-msg" role="alert">{scanMsg}</p>}
|
||||
<Link to="/redeem/phone" className="shop-home-phone-link">
|
||||
<span className="material-symbols-outlined">smartphone</span>
|
||||
手机号核销
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
<section className="shop-home-status">
|
||||
<div className="shop-home-status-left">
|
||||
<div className={`shop-home-status-icon${open ? '' : ' closed'}`}>
|
||||
<span className="material-symbols-outlined shop-fill-icon">schedule</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="shop-home-status-title">营业状态</p>
|
||||
<p className="shop-home-status-sub">{statusText}</p>
|
||||
<p className="shop-home-status-sub">营业时间: {hoursText}</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="shop-home-switch" onClick={() => navigate('/status')}>
|
||||
<input type="checkbox" checked={open && status !== 'CLOSED'} readOnly tabIndex={-1} />
|
||||
<span className="shop-home-switch-track" />
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="shop-home-records-head">
|
||||
<h3 className="shop-home-records-title">核销记录</h3>
|
||||
<Link to="/records" className="shop-home-records-link">
|
||||
查看全部
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>chevron_right</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="shop-home-record-list">
|
||||
{recent.length === 0 && (
|
||||
<p className="shop-home-status-sub" style={{ textAlign: 'center', padding: '16px 0' }}>暂无核销记录</p>
|
||||
)}
|
||||
{recent.map((r) => (
|
||||
<div key={String(r.id)} className="shop-home-record-item">
|
||||
<div>
|
||||
<p className="shop-home-record-time">核销时间</p>
|
||||
<p className="shop-home-record-value">
|
||||
{new Date(String(r.createdAt)).toLocaleString('zh-CN')}
|
||||
</p>
|
||||
</div>
|
||||
<p className="shop-home-record-amount">¥{formatMoney(Number(r.amount))}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<WechatScanAuthModal
|
||||
open={authModalOpen}
|
||||
loading={authLoading}
|
||||
error={authError}
|
||||
onAuthorize={() => void startWechatAuth()}
|
||||
onCancel={() => {
|
||||
setAuthModalOpen(false);
|
||||
setAuthError('');
|
||||
sessionStorage.removeItem(PENDING_SCAN_KEY);
|
||||
}}
|
||||
/>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
|
||||
import { isScanPermissionWarmupError } from '@dukang/weixin-sdk';
|
||||
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
|
||||
import { request } from '../lib/api';
|
||||
|
||||
import { parseRedeemTokenFromScan } from '../lib/redeem-scan';
|
||||
|
||||
import {
|
||||
|
||||
authorizeShopWechat,
|
||||
|
||||
checkNeedsWechatAuth,
|
||||
|
||||
fetchShopAccount,
|
||||
|
||||
} from '../lib/wechat-auth';
|
||||
|
||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||
|
||||
import {
|
||||
|
||||
clearPendingScanAfterAuth,
|
||||
|
||||
getPostAuthScanDelayMs,
|
||||
|
||||
markPendingScanAfterAuth,
|
||||
|
||||
peekPendingScanAfterAuth,
|
||||
|
||||
} from '../lib/shop-scan-auth';
|
||||
|
||||
import WechatScanAuthModal from '../components/WechatScanAuthModal';
|
||||
|
||||
import { useStorePageView } from '../lib/usePageView';
|
||||
|
||||
import { trackStore } from '../lib/analytics';
|
||||
|
||||
|
||||
|
||||
function formatMoney(n: number) {
|
||||
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function formatScanError(e: unknown, opts?: { afterAuth?: boolean }): string {
|
||||
|
||||
const msg = e instanceof Error ? e.message : '扫码失败,请重试';
|
||||
|
||||
if (/invalid signature/i.test(msg)) {
|
||||
|
||||
return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名,并刷新页面后重试';
|
||||
|
||||
}
|
||||
|
||||
if (isScanPermissionWarmupError(msg)) {
|
||||
|
||||
if (opts?.afterAuth) {
|
||||
|
||||
return '微信授权已完成,扫码权限仍在准备中,请再点一次「扫码核销」';
|
||||
|
||||
}
|
||||
|
||||
return '微信权限校验尚未完成,请等待 1~2 秒后再次点击扫码(首次绑定微信时较常见)';
|
||||
|
||||
}
|
||||
|
||||
return msg;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default function HomePage() {
|
||||
|
||||
useStorePageView('store_home_view');
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { ready, authenticated } = useStoreSession();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
|
||||
|
||||
const [scanMsg, setScanMsg] = useState('');
|
||||
|
||||
const [scanning, setScanning] = useState(false);
|
||||
|
||||
const [authModalOpen, setAuthModalOpen] = useState(false);
|
||||
|
||||
const [authLoading, setAuthLoading] = useState(false);
|
||||
|
||||
const [authError, setAuthError] = useState('');
|
||||
|
||||
const pendingScanStartedRef = useRef(false);
|
||||
|
||||
|
||||
|
||||
const loadDashboard = useCallback(() => {
|
||||
|
||||
return request<Record<string, unknown>>('SHOP_H5', '/shop/dashboard')
|
||||
|
||||
.then((d) => {
|
||||
|
||||
setDash(d);
|
||||
|
||||
})
|
||||
|
||||
.catch(() => {});
|
||||
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
void loadDashboard();
|
||||
|
||||
}, [loadDashboard]);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
function onResume() {
|
||||
|
||||
setScanning(false);
|
||||
|
||||
void loadDashboard();
|
||||
|
||||
}
|
||||
|
||||
function onVisibility() {
|
||||
|
||||
if (document.visibilityState === 'visible') onResume();
|
||||
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
|
||||
window.addEventListener('pageshow', onResume);
|
||||
|
||||
window.addEventListener('focus', onResume);
|
||||
|
||||
return () => {
|
||||
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
|
||||
window.removeEventListener('pageshow', onResume);
|
||||
|
||||
window.removeEventListener('focus', onResume);
|
||||
|
||||
};
|
||||
|
||||
}, [loadDashboard]);
|
||||
|
||||
|
||||
|
||||
const runScan = useCallback(
|
||||
|
||||
async (opts?: { postAuthWarmup?: boolean }) => {
|
||||
|
||||
trackStore('store_redeem_scan_start');
|
||||
|
||||
if (!isWechatEnv()) {
|
||||
|
||||
setScanMsg('请在微信内打开门店端进行扫码核销');
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
setScanning(true);
|
||||
|
||||
if (!opts?.postAuthWarmup) {
|
||||
|
||||
setScanMsg('');
|
||||
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
if (opts?.postAuthWarmup) {
|
||||
|
||||
weixinSdk.reset();
|
||||
|
||||
}
|
||||
|
||||
await weixinSdk.init();
|
||||
|
||||
const raw = await weixinSdk.scanQrCode(
|
||||
|
||||
opts?.postAuthWarmup ? { postAuthWarmup: true } : undefined,
|
||||
|
||||
);
|
||||
|
||||
if (!raw) {
|
||||
|
||||
void loadDashboard();
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
const token = parseRedeemTokenFromScan(raw);
|
||||
|
||||
if (!token) {
|
||||
|
||||
setScanMsg('无法识别核销码,请扫描用户出示的核销二维码');
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
navigate(`/redeem?token=${encodeURIComponent(token)}`);
|
||||
|
||||
} catch (e) {
|
||||
|
||||
setScanMsg(formatScanError(e, { afterAuth: opts?.postAuthWarmup }));
|
||||
|
||||
} finally {
|
||||
|
||||
setScanning(false);
|
||||
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
[loadDashboard, navigate],
|
||||
|
||||
);
|
||||
|
||||
|
||||
|
||||
// OAuth 由 StoreSessionContext 单例处理;此处仅在授权完成后续扫
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
if (!ready || !authenticated || !isWechatEnv()) return;
|
||||
|
||||
if (searchParams.get('code')) return;
|
||||
|
||||
if (!peekPendingScanAfterAuth() || pendingScanStartedRef.current) return;
|
||||
|
||||
|
||||
|
||||
pendingScanStartedRef.current = true;
|
||||
|
||||
clearPendingScanAfterAuth();
|
||||
|
||||
setAuthModalOpen(false);
|
||||
|
||||
setAuthLoading(false);
|
||||
|
||||
setAuthError('');
|
||||
|
||||
setScanMsg('微信授权成功,正在准备扫码…');
|
||||
|
||||
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
|
||||
void runScan({ postAuthWarmup: true });
|
||||
|
||||
}, getPostAuthScanDelayMs());
|
||||
|
||||
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
|
||||
}, [ready, authenticated, searchParams, runScan]);
|
||||
|
||||
|
||||
|
||||
async function handleScan() {
|
||||
|
||||
setScanMsg('');
|
||||
|
||||
if (!isWechatEnv()) {
|
||||
|
||||
setScanMsg('请在微信内打开门店端进行扫码核销');
|
||||
|
||||
return;
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
consumeShopWechatLoginHint,
|
||||
fetchClientConfig,
|
||||
formatShopWechatError,
|
||||
handleShopWechatCallback,
|
||||
handleShopWechatCallbackOnce,
|
||||
handleShopWechatLoginResult,
|
||||
loginShopWithWechat,
|
||||
} from '../lib/wechat-auth';
|
||||
@@ -88,7 +88,7 @@ export default function LoginPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWechatEnv() || !wxAuthorize || !params.get('code')) return;
|
||||
void handleShopWechatCallback()
|
||||
void handleShopWechatCallbackOnce()
|
||||
.then((result) => {
|
||||
if (!result) return;
|
||||
const session = handleShopWechatLoginResult(result);
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
authorizeShopWechat,
|
||||
fetchClientConfig,
|
||||
fetchShopAccount,
|
||||
handleShopWechatCallback,
|
||||
handleShopWechatCallbackOnce,
|
||||
handleShopWechatLoginResult,
|
||||
} from '../lib/wechat-auth';
|
||||
import { isWechatEnv } from '../lib/weixin';
|
||||
@@ -45,7 +45,7 @@ export default function MinePage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWechatEnv() || !wxAuthorize || !searchParams.get('code')) return;
|
||||
void handleShopWechatCallback()
|
||||
void handleShopWechatCallbackOnce()
|
||||
.then((result) => {
|
||||
if (!result) return;
|
||||
const session = handleShopWechatLoginResult(result);
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function ProductCarousel({
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const prefix =
|
||||
variant === 'store' ? 'store-detail-carousel' : variant === 'home' ? 'home-carousel' : 'detail-carousel';
|
||||
const imageMode = variant === 'store' ? 'aspectFit' : 'aspectFill';
|
||||
const imageMode = 'aspectFill';
|
||||
|
||||
function previewAt(index: number) {
|
||||
const urls = slides.filter(Boolean);
|
||||
|
||||
@@ -4,6 +4,8 @@ import { fetchClientConfig } from './pay-wechat';
|
||||
/** 与 package.json version 同步,供服务端 minClientVersion 比对 */
|
||||
export const APP_VERSION = '3.4.13';
|
||||
|
||||
export const APP_VERSION_LABEL = `v${APP_VERSION}`;
|
||||
|
||||
function parseSemver(v: string): number[] {
|
||||
return v.split('.').map((n) => parseInt(n, 10) || 0);
|
||||
}
|
||||
@@ -20,17 +22,76 @@ export function compareSemver(a: string, b: string): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
function exitMiniProgramIfSupported() {
|
||||
if (process.env.TARO_ENV !== 'weapp') return;
|
||||
if (typeof Taro.exitMiniProgram !== 'function') return;
|
||||
void Taro.exitMiniProgram({});
|
||||
}
|
||||
|
||||
/** 微信 CDN 有新包时引导 applyUpdate;返回是否已触发更新流程 */
|
||||
function tryWeappForceUpdate(min: string): Promise<boolean> {
|
||||
if (process.env.TARO_ENV !== 'weapp' || typeof Taro.getUpdateManager !== 'function') {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const manager = Taro.getUpdateManager();
|
||||
let settled = false;
|
||||
const finish = (updated: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(updated);
|
||||
};
|
||||
|
||||
manager.onCheckForUpdate((res) => {
|
||||
if (!res.hasUpdate) {
|
||||
finish(false);
|
||||
return;
|
||||
}
|
||||
manager.onUpdateReady(() => {
|
||||
void Taro.showModal({
|
||||
title: '版本过低',
|
||||
content: `当前 ${APP_VERSION_LABEL} 低于要求版本 v${min},已检测到新版本,请立即更新后继续使用。`,
|
||||
showCancel: false,
|
||||
confirmText: '立即更新',
|
||||
success: (r) => {
|
||||
if (r.confirm) {
|
||||
manager.applyUpdate();
|
||||
finish(true);
|
||||
} else {
|
||||
finish(false);
|
||||
}
|
||||
},
|
||||
fail: () => finish(false),
|
||||
});
|
||||
});
|
||||
manager.onUpdateFailed(() => finish(false));
|
||||
});
|
||||
|
||||
setTimeout(() => finish(false), 4000);
|
||||
});
|
||||
}
|
||||
|
||||
async function enforceMinClientVersion(min: string) {
|
||||
const updated = await tryWeappForceUpdate(min);
|
||||
if (updated) return;
|
||||
|
||||
await Taro.showModal({
|
||||
title: '版本过低',
|
||||
content: `当前 ${APP_VERSION_LABEL} 低于要求版本 v${min},请更新至最新版本后继续使用。`,
|
||||
showCancel: false,
|
||||
confirmText: '我知道了',
|
||||
});
|
||||
|
||||
exitMiniProgramIfSupported();
|
||||
}
|
||||
|
||||
export async function checkClientVersionGate() {
|
||||
try {
|
||||
const config = await fetchClientConfig();
|
||||
const min = config.minClientVersion?.trim();
|
||||
if (min && compareSemver(APP_VERSION, min) < 0) {
|
||||
await Taro.showModal({
|
||||
title: '版本过低',
|
||||
content: '当前小程序版本过低,请更新至最新版本后继续使用。',
|
||||
showCancel: false,
|
||||
confirmText: '我知道了',
|
||||
});
|
||||
await enforceMinClientVersion(min);
|
||||
}
|
||||
} catch {
|
||||
/* 配置拉取失败不阻塞启动 */
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from '../../lib/mini-wechat-profile';
|
||||
import { isLoggedIn, logout, request, toast, type UserProfile } from '../../lib/api';
|
||||
import { isWechatEnv } from '../../lib/weixin';
|
||||
import { APP_VERSION_LABEL } from '../../lib/client-version';
|
||||
import { maskPhone } from '../../lib/phone';
|
||||
import {
|
||||
DEFAULT_SHARE_DESC,
|
||||
@@ -499,7 +500,7 @@ export default function MinePage() {
|
||||
</View>
|
||||
|
||||
<View className="mine-footer">
|
||||
<Text className="mine-version">杜康好客</Text>
|
||||
<Text className="mine-version">杜康好客 {APP_VERSION_LABEL}</Text>
|
||||
<Text className="mine-logout" onClick={() => logout()}>
|
||||
退出登录
|
||||
</Text>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { View, Text, Image } from '@tarojs/components';
|
||||
import { View, Text, Image, ScrollView } from '@tarojs/components';
|
||||
import Taro, {
|
||||
useDidShow,
|
||||
useLoad,
|
||||
@@ -156,10 +156,14 @@ export default function StoreDetailPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [headerSolid, setHeaderSolid] = useState(false);
|
||||
const [expandedPackages, setExpandedPackages] = useState<Record<number, boolean>>({});
|
||||
const [activePackageIndex, setActivePackageIndex] = useState(0);
|
||||
const storeRef = useRef<Store | null>(null);
|
||||
storeRef.current = store;
|
||||
|
||||
useEffect(() => {
|
||||
setActivePackageIndex(0);
|
||||
}, [store?.id]);
|
||||
|
||||
usePageScroll(({ scrollTop }) => {
|
||||
setHeaderSolid(scrollTop > 100);
|
||||
});
|
||||
@@ -321,6 +325,8 @@ export default function StoreDetailPage() {
|
||||
|
||||
const envPhotos = envPhotoUrls(store);
|
||||
const heroImages = uniqueUrls([store.coverUrl, ...(store.carouselUrls || [])]);
|
||||
const packages = store.packages ?? [];
|
||||
const activePackage = packages[activePackageIndex] ?? packages[0];
|
||||
|
||||
const intro = store.intro?.trim() || '';
|
||||
const benefitRuleRaw = store.benefitUsageRule?.trim() || '';
|
||||
@@ -402,44 +408,49 @@ export default function StoreDetailPage() {
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{store.packages && store.packages.length > 0 ? (
|
||||
<View className="store-detail-section">
|
||||
{packages.length > 0 && activePackage ? (
|
||||
<View className="store-detail-section store-detail-section--packages">
|
||||
<Text className="store-detail-section-title">门店套餐</Text>
|
||||
{store.packages.map((pkg, index) => {
|
||||
const expanded = !!expandedPackages[index];
|
||||
const bodyText = `${formatRedeemAmountYuan(pkg.price)} 元 · ${pkg.dishes}`;
|
||||
const longBody = bodyText.length > 48 || (pkg.otherNotes?.length ?? 0) > 40;
|
||||
return (
|
||||
<View
|
||||
key={`${pkg.name}-${index}`}
|
||||
className={`store-detail-package-card${expanded || !longBody ? '' : ' store-detail-package-card--collapsed'}`}
|
||||
>
|
||||
{pkg.imageUrl ? (
|
||||
<Image className="store-detail-package-img" src={pkg.imageUrl} mode="aspectFill" />
|
||||
) : null}
|
||||
<View className="store-detail-package-head">
|
||||
<Text className="store-detail-package-name">{pkg.name}</Text>
|
||||
{longBody ? (
|
||||
<Text
|
||||
className="store-detail-package-toggle"
|
||||
onClick={() =>
|
||||
setExpandedPackages((prev) => ({ ...prev, [index]: !prev[index] }))
|
||||
}
|
||||
{packages.length > 1 ? (
|
||||
<ScrollView className="store-detail-package-tabs" scrollX showScrollbar={false} enhanced>
|
||||
<View className="store-detail-package-tabs-inner">
|
||||
{packages.map((pkg, index) => (
|
||||
<View
|
||||
key={`${pkg.name}-${index}`}
|
||||
className={`store-detail-package-tab${
|
||||
index === activePackageIndex ? ' store-detail-package-tab--active' : ''
|
||||
}`}
|
||||
onClick={() => setActivePackageIndex(index)}
|
||||
>
|
||||
{expanded ? '收起' : '展开'}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text className="store-detail-package-tab-text">{pkg.name}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<Text className="store-detail-package-body">{bodyText}</Text>
|
||||
{pkg.usableTime ? (
|
||||
<Text className="store-detail-package-meta">使用时间:{pkg.usableTime}</Text>
|
||||
</ScrollView>
|
||||
) : null}
|
||||
<View className="store-detail-package-panel">
|
||||
{activePackage.imageUrl ? (
|
||||
<Image
|
||||
className="store-detail-package-thumb"
|
||||
src={activePackage.imageUrl}
|
||||
mode="aspectFill"
|
||||
/>
|
||||
) : null}
|
||||
<View className="store-detail-package-panel-body">
|
||||
{packages.length === 1 ? (
|
||||
<Text className="store-detail-package-name">{activePackage.name}</Text>
|
||||
) : null}
|
||||
{pkg.otherNotes ? (
|
||||
<Text className="store-detail-package-meta">说明:{pkg.otherNotes}</Text>
|
||||
<Text className="store-detail-package-body">
|
||||
{formatRedeemAmountYuan(activePackage.price)} 元 · {activePackage.dishes}
|
||||
</Text>
|
||||
{activePackage.usableTime ? (
|
||||
<Text className="store-detail-package-meta">使用时间:{activePackage.usableTime}</Text>
|
||||
) : null}
|
||||
{activePackage.otherNotes ? (
|
||||
<Text className="store-detail-package-meta">说明:{activePackage.otherNotes}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -343,11 +343,10 @@ export default function StoresPage() {
|
||||
}
|
||||
|
||||
function formatHours(store: Store) {
|
||||
// 列表只展示第一段营业时间,避免挤占一行
|
||||
if (store.openTime && store.closeTime) {
|
||||
return `营业时间: ${store.openTime}-${store.closeTime}`;
|
||||
}
|
||||
return '营业时间: 10:00-22:00';
|
||||
const parts: string[] = [];
|
||||
if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`);
|
||||
if (store.openTime2 && store.closeTime2) parts.push(`${store.openTime2}-${store.closeTime2}`);
|
||||
return parts.length ? `营业时间: ${parts.join(',')}` : '营业时间: 10:00-22:00';
|
||||
}
|
||||
|
||||
function formatStatus(store: Store) {
|
||||
@@ -447,12 +446,10 @@ export default function StoresPage() {
|
||||
{formatDistanceMeters(s.distanceMeters)}
|
||||
</Text>
|
||||
</View>
|
||||
{/* 第2行:状态 + 营业时间(仅第一段) */}
|
||||
{/* 第2行:状态 + 营业时间(含第二段) */}
|
||||
<View className="store-card-row store-card-row--meta">
|
||||
<Text className="store-card-status">{formatStatus(s)}</Text>
|
||||
<Text className="store-card-hours" numberOfLines={1}>
|
||||
{formatHours(s)}
|
||||
</Text>
|
||||
<Text className="store-card-hours">{formatHours(s)}</Text>
|
||||
</View>
|
||||
{/* 第3行:地址 + 去核销 */}
|
||||
<View className="store-card-row store-card-row--foot">
|
||||
|
||||
@@ -10,15 +10,14 @@
|
||||
.store-detail-carousel-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
max-height: 360px;
|
||||
aspect-ratio: 4 / 3;
|
||||
overflow: hidden;
|
||||
background: var(--color-surface-container);
|
||||
}
|
||||
|
||||
.store-detail-carousel {
|
||||
width: 100%;
|
||||
height: 280px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.store-detail-carousel-item,
|
||||
@@ -28,6 +27,11 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.store-detail-carousel-image {
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.store-detail-carousel-placeholder {
|
||||
background: var(--color-surface-container);
|
||||
}
|
||||
@@ -238,62 +242,102 @@
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.store-detail-package-card {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
.store-detail-section--packages {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.store-detail-package-card--collapsed .store-detail-package-body,
|
||||
.store-detail-package-card--collapsed .store-detail-package-meta {
|
||||
.store-detail-package-tabs {
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.store-detail-package-tabs-inner {
|
||||
display: inline-flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.store-detail-package-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
max-width: 132px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.store-detail-package-tab--active {
|
||||
background: rgba(166, 29, 36, 0.1);
|
||||
}
|
||||
|
||||
.store-detail-package-tab-text {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--color-text-secondary, #666);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.store-detail-package-tab--active .store-detail-package-tab-text {
|
||||
color: var(--color-heritage-red, #a61d24);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.store-detail-package-panel {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
border-radius: var(--radius-md, 8px);
|
||||
background: var(--color-surface-container, #f7f7f7);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.store-detail-package-thumb {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.store-detail-package-panel-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.store-detail-package-body,
|
||||
.store-detail-package-meta {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.store-detail-package-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.store-detail-package-toggle {
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
color: var(--color-heritage-red);
|
||||
}
|
||||
|
||||
.store-detail-package-img {
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
border-radius: var(--radius-md, 8px);
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.store-detail-package-card:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.store-detail-package-name {
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary, #1a1a1a);
|
||||
margin-bottom: 6px;
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.store-detail-package-body {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary, #666);
|
||||
line-height: 1.5;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.store-detail-package-meta {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-tertiary, #999);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@@ -218,15 +218,17 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 第2行:营业状态 + 营业时间(单行) */
|
||||
/* 第2行:营业状态 + 营业时间(可含两段) */
|
||||
.store-card-row--meta {
|
||||
gap: 6px;
|
||||
height: 20px;
|
||||
min-height: 20px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.store-card-status {
|
||||
flex-shrink: 0;
|
||||
padding: 0 6px;
|
||||
margin-top: 1px;
|
||||
border-radius: 4px;
|
||||
background: rgba(45, 106, 79, 0.12);
|
||||
color: #2d6a4f;
|
||||
@@ -241,11 +243,8 @@
|
||||
min-width: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
line-height: 16px;
|
||||
color: #999;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 第3行:地址(单行截断)+ 右对齐去核销 */
|
||||
|
||||
@@ -29,6 +29,15 @@ describe('validateSupportTicketStatusTransition', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('allows PASSED -> PUBLISHED', () => {
|
||||
expect(
|
||||
validateSupportTicketStatusTransition('PASSED', 'PUBLISHED', {
|
||||
linkedTaskCount: 1,
|
||||
isSuperAdmin: true,
|
||||
}).ok,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('requires super admin and reason for REJECTED', () => {
|
||||
expect(
|
||||
validateSupportTicketStatusTransition('DEVELOPING', 'REJECTED', {
|
||||
|
||||
@@ -3,7 +3,8 @@ export type SupportTicketStatus =
|
||||
| 'REJECTED'
|
||||
| 'DEVELOPING'
|
||||
| 'TESTING'
|
||||
| 'PASSED';
|
||||
| 'PASSED'
|
||||
| 'PUBLISHED';
|
||||
|
||||
export type SupportTicketStatusTransitionContext = {
|
||||
linkedTaskCount: number;
|
||||
@@ -16,7 +17,8 @@ const FORWARD_TRANSITIONS: Record<SupportTicketStatus, SupportTicketStatus[]> =
|
||||
DEVELOPING: ['TESTING', 'REJECTED'],
|
||||
TESTING: ['PASSED', 'REJECTED'],
|
||||
REJECTED: [],
|
||||
PASSED: [],
|
||||
PASSED: ['PUBLISHED'],
|
||||
PUBLISHED: [],
|
||||
};
|
||||
|
||||
/** 技术支持工单批量/人工改状态校验(禁止回退) */
|
||||
|
||||
@@ -85,6 +85,50 @@ export function promoConversion(scan: number, orders: number): string {
|
||||
return `${Math.round((orders / scan) * 1000) / 10}%`;
|
||||
}
|
||||
|
||||
export type PromoMetricEventType = 'SCAN' | 'ATTRIBUTION' | 'REGISTER' | 'ORDER';
|
||||
|
||||
export const PROMO_METRIC_EVENT_LABELS: Record<PromoMetricEventType, string> = {
|
||||
SCAN: '扫码进入',
|
||||
ATTRIBUTION: '归因用户',
|
||||
REGISTER: '扫码注册',
|
||||
ORDER: '订单',
|
||||
};
|
||||
|
||||
export type PromoMetricEventItem = {
|
||||
id: string;
|
||||
eventType: PromoMetricEventType;
|
||||
userId: string | null;
|
||||
orderId: string | null;
|
||||
sessionId: string | null;
|
||||
clientIp: string | null;
|
||||
ipProvince: string | null;
|
||||
ipCity: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type PromoMetricTimelineBucket = {
|
||||
key: string;
|
||||
scan: number;
|
||||
attribution: number;
|
||||
register: number;
|
||||
order: number;
|
||||
};
|
||||
|
||||
export type PromoMetricTimelinePeak = {
|
||||
scan: { key: string; count: number } | null;
|
||||
attribution: { key: string; count: number } | null;
|
||||
register: { key: string; count: number } | null;
|
||||
order: { key: string; count: number } | null;
|
||||
};
|
||||
|
||||
export type PromoMetricTimelineDto = {
|
||||
granularity: 'day' | 'hour';
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
buckets: PromoMetricTimelineBucket[];
|
||||
peak: PromoMetricTimelinePeak;
|
||||
};
|
||||
|
||||
export function buildPromoLandingUrl(baseUrl: string, code: string, qrcodeId: string): string {
|
||||
const base = baseUrl.replace(/\/$/, '');
|
||||
return `${base}/?promo=${encodeURIComponent(code)}&pid=${encodeURIComponent(qrcodeId)}`;
|
||||
|
||||
@@ -15,7 +15,8 @@ export type SupportTicketStatusDto =
|
||||
| 'REJECTED'
|
||||
| 'DEVELOPING'
|
||||
| 'TESTING'
|
||||
| 'PASSED';
|
||||
| 'PASSED'
|
||||
| 'PUBLISHED';
|
||||
|
||||
export const SUPPORT_TICKET_STATUSES = [
|
||||
'PENDING_REVIEW',
|
||||
@@ -23,6 +24,7 @@ export const SUPPORT_TICKET_STATUSES = [
|
||||
'DEVELOPING',
|
||||
'TESTING',
|
||||
'PASSED',
|
||||
'PUBLISHED',
|
||||
] as const;
|
||||
|
||||
export const SUPPORT_TICKET_STATUS_LABELS: Record<SupportTicketStatusDto, string> = {
|
||||
@@ -31,6 +33,7 @@ export const SUPPORT_TICKET_STATUS_LABELS: Record<SupportTicketStatusDto, string
|
||||
DEVELOPING: '开发',
|
||||
TESTING: '测试',
|
||||
PASSED: '通过',
|
||||
PUBLISHED: '已发布',
|
||||
};
|
||||
|
||||
/** 技术支持工单优先级 */
|
||||
@@ -64,6 +67,8 @@ export interface SupportTicketDto {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
completedAt?: string | null;
|
||||
releasedVersionNo?: string | null;
|
||||
publishedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateSupportTicketRequest {
|
||||
|
||||
@@ -27,6 +27,30 @@ export const TICKET_TYPE_LABELS: Record<TicketTypeDto, string> = {
|
||||
PACKAGE_DISPUTE: '套餐异议',
|
||||
};
|
||||
|
||||
/** 售后工单状态(common_ticket.status) */
|
||||
export type TicketStatusDto =
|
||||
| 'PENDING'
|
||||
| 'OPEN'
|
||||
| 'COLLABORATING'
|
||||
| 'RESOLVED'
|
||||
| 'REJECTED'
|
||||
| 'COMPLETED'
|
||||
| 'CLOSED';
|
||||
|
||||
export const TICKET_STATUS_LABELS: Record<TicketStatusDto, string> = {
|
||||
PENDING: '待处理',
|
||||
OPEN: '处理中',
|
||||
COLLABORATING: '协同中',
|
||||
RESOLVED: '已解决',
|
||||
REJECTED: '已驳回',
|
||||
COMPLETED: '已完成',
|
||||
CLOSED: '已关闭',
|
||||
};
|
||||
|
||||
export function ticketStatusLabel(status: string): string {
|
||||
return TICKET_STATUS_LABELS[status as TicketStatusDto] ?? status;
|
||||
}
|
||||
|
||||
export interface TicketDto {
|
||||
id: string;
|
||||
ticketNo: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { isWechatBrowser, isMiniProgram, getRuntimePlatform } from './env';
|
||||
export { isWechatBrowser, isMiniProgram, getRuntimePlatform, isIosDevice, isWechatDevTools } from './env';
|
||||
export { getRouterBasename, toAppPath, isOnAppPath } from './app-path';
|
||||
export {
|
||||
initWechatJssdk,
|
||||
@@ -10,7 +10,7 @@ export {
|
||||
resetJssdkConfig,
|
||||
stripOAuthParamsFromLocation,
|
||||
} from './jssdk';
|
||||
export { formatScanFailMessage } from './scan';
|
||||
export { formatScanFailMessage, isScanPermissionWarmupError } from './scan';
|
||||
export {
|
||||
getWechatLocation,
|
||||
getWechatLocationDetailed,
|
||||
|
||||
@@ -38,7 +38,7 @@ export function formatScanFailMessage(errMsg: string): string {
|
||||
return msg;
|
||||
}
|
||||
|
||||
function isScanPermissionWarmupError(msg: string): boolean {
|
||||
export function isScanPermissionWarmupError(msg: string): boolean {
|
||||
return /offline verifying|permission value is offline|权限验证中|接口未就绪|invalid signature|config:fail|signature/i.test(
|
||||
msg,
|
||||
);
|
||||
@@ -134,15 +134,13 @@ export async function scanQrCode(
|
||||
throw new Error('当前微信版本不支持扫码,请升级微信后重试');
|
||||
}
|
||||
|
||||
// wx.ready ≠ 权限离线校验完成;授权回跳后更明显
|
||||
const warmupMs = options.postAuthWarmup
|
||||
? 1200
|
||||
: isIosDevice() && !isWechatDevTools()
|
||||
? 800
|
||||
: 300;
|
||||
const ios = isIosDevice() && !isWechatDevTools();
|
||||
|
||||
// wx.ready ≠ 权限离线校验完成;OAuth 回跳后 iOS 更明显
|
||||
const warmupMs = options.postAuthWarmup ? (ios ? 1600 : 1000) : ios ? 800 : 300;
|
||||
await delay(warmupMs);
|
||||
|
||||
const maxAttempts = options.postAuthWarmup ? 3 : 2;
|
||||
const maxAttempts = options.postAuthWarmup ? (ios ? 4 : 3) : 2;
|
||||
let lastError: Error | null = null;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
if (attempt > 0) {
|
||||
@@ -151,7 +149,8 @@ export async function scanQrCode(
|
||||
await initWechatJssdk({ ...jssdkOptions, url: getJssdkSignUrl() });
|
||||
await checkJsApi('scanQRCode');
|
||||
}
|
||||
await delay(700 + attempt * 500);
|
||||
const retryDelay = options.postAuthWarmup && ios ? 900 + attempt * 700 : 700 + attempt * 500;
|
||||
await delay(retryDelay);
|
||||
}
|
||||
try {
|
||||
return await invokeScanQrCode();
|
||||
|
||||
@@ -107,6 +107,7 @@ enum SupportTicketStatus {
|
||||
DEVELOPING
|
||||
TESTING
|
||||
PASSED
|
||||
PUBLISHED
|
||||
}
|
||||
|
||||
enum SupportTicketPriority {
|
||||
@@ -181,6 +182,13 @@ enum PromoCodeScene {
|
||||
OTHER
|
||||
}
|
||||
|
||||
enum PromoMetricEventType {
|
||||
SCAN
|
||||
ATTRIBUTION
|
||||
REGISTER
|
||||
ORDER
|
||||
}
|
||||
|
||||
enum CityStatus {
|
||||
PENDING
|
||||
ACTIVE
|
||||
@@ -664,6 +672,8 @@ model CommonSupportTicket {
|
||||
reviewedAt DateTime? @map("reviewed_at") @db.DateTime(3)
|
||||
remark String? @db.VarChar(512)
|
||||
attachmentUrls Json? @map("attachment_urls")
|
||||
releasedVersionNo String? @map("released_version_no") @db.VarChar(32)
|
||||
publishedAt DateTime? @map("published_at") @db.DateTime(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
completedAt DateTime? @map("completed_at") @db.DateTime(3)
|
||||
@@ -671,6 +681,7 @@ model CommonSupportTicket {
|
||||
@@index([status, createdAt])
|
||||
@@index([ticketType, status])
|
||||
@@index([creatorId])
|
||||
@@index([releasedVersionNo])
|
||||
@@map("common_support_ticket")
|
||||
|
||||
devPlanTasks DevPlanTask[]
|
||||
@@ -856,6 +867,7 @@ model CommonPromoCode {
|
||||
qrcodeResource CommonResource? @relation("PromoQrcode", fields: [qrcodeResourceId], references: [id], onDelete: SetNull)
|
||||
attributions UserPromoAttribution[]
|
||||
orders Order[]
|
||||
metricEvents LogPromoEvent[]
|
||||
|
||||
@@index([ownerUserId])
|
||||
@@index([scene, status])
|
||||
@@ -1759,6 +1771,25 @@ model LogThirdParty {
|
||||
@@map("log_third_party")
|
||||
}
|
||||
|
||||
model LogPromoEvent {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
promoCodeId BigInt @map("promo_code_id") @db.UnsignedBigInt
|
||||
eventType PromoMetricEventType @map("event_type")
|
||||
userId BigInt? @map("user_id") @db.UnsignedBigInt
|
||||
orderId BigInt? @map("order_id") @db.UnsignedBigInt
|
||||
sessionId String? @map("session_id") @db.VarChar(64)
|
||||
clientIp String? @map("client_ip") @db.VarChar(45)
|
||||
ipProvince String? @map("ip_province") @db.VarChar(32)
|
||||
ipCity String? @map("ip_city") @db.VarChar(32)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
promoCode CommonPromoCode @relation(fields: [promoCodeId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([promoCodeId, eventType, createdAt])
|
||||
@@index([createdAt])
|
||||
@@map("log_promo_event")
|
||||
}
|
||||
|
||||
model LogUserAnalytics {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
userId BigInt? @map("user_id") @db.UnsignedBigInt
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { BadRequestException, Body, Controller, Post, UseGuards } from '@nestjs/common';
|
||||
import { BadRequestException, Body, Controller, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { AnalyticsService } from './analytics.service';
|
||||
import { PromoCodeService } from '../promo/promo-code.service';
|
||||
import { extractClientIp } from '../../common/geo/client-ip.util';
|
||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
@@ -66,8 +68,13 @@ export class PromoController {
|
||||
|
||||
@Post('touch')
|
||||
@UseGuards(OptionalJwtAuthGuard)
|
||||
async touch(@CurrentUser() user: AuthUser | undefined, @Body() dto: PromoTouchDto) {
|
||||
async touch(
|
||||
@CurrentUser() user: AuthUser | undefined,
|
||||
@Body() dto: PromoTouchDto,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const userId = user?.actorType === ActorType.USER ? user.actorId : undefined;
|
||||
const clientIp = extractClientIp(req) ?? undefined;
|
||||
const result = await this.promoCodeService.touch(
|
||||
{
|
||||
promoCode: dto.promoCode,
|
||||
@@ -76,6 +83,7 @@ export class PromoController {
|
||||
countScan: dto.countScan,
|
||||
},
|
||||
userId,
|
||||
{ clientIp, sessionId: dto.sessionId },
|
||||
);
|
||||
|
||||
void this.analyticsService.trackBatchOptional(
|
||||
|
||||
@@ -671,6 +671,10 @@ export class DevPlanService {
|
||||
|
||||
}
|
||||
|
||||
if (status === 'RELEASED') {
|
||||
await this.cascadeVersionReleased(row.id, row.versionNo);
|
||||
}
|
||||
|
||||
return this.getVersion(row.id);
|
||||
|
||||
}
|
||||
@@ -739,6 +743,14 @@ export class DevPlanService {
|
||||
|
||||
await this.prisma.devPlanVersion.update({ where: { id }, data });
|
||||
|
||||
if (
|
||||
dto.status === 'RELEASED' &&
|
||||
existing.status !== 'RELEASED'
|
||||
) {
|
||||
const versionNo = (dto.versionNo ?? existing.versionNo).trim();
|
||||
await this.cascadeVersionReleased(id, versionNo);
|
||||
}
|
||||
|
||||
if (dto.taskIds != null) await this.replaceVersionTasks(id, dto.taskIds);
|
||||
|
||||
return this.getVersion(id);
|
||||
@@ -775,6 +787,46 @@ export class DevPlanService {
|
||||
|
||||
}
|
||||
|
||||
private async cascadeVersionReleased(versionId: bigint, versionNo: string) {
|
||||
const links = await this.prisma.devPlanVersionTask.findMany({
|
||||
where: { versionId },
|
||||
select: { taskId: true },
|
||||
});
|
||||
if (!links.length) return;
|
||||
|
||||
const taskIds = links.map((l) => l.taskId);
|
||||
const now = new Date();
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.devPlanTask.updateMany({
|
||||
where: { id: { in: taskIds }, status: { not: 'RELEASED' } },
|
||||
data: { status: 'RELEASED', completedAt: now },
|
||||
});
|
||||
|
||||
const tasks = await tx.devPlanTask.findMany({
|
||||
where: { id: { in: taskIds }, supportTicketId: { not: null } },
|
||||
select: { supportTicketId: true },
|
||||
});
|
||||
const ticketIds = [
|
||||
...new Set(
|
||||
tasks
|
||||
.map((t) => t.supportTicketId)
|
||||
.filter((id): id is bigint => id != null),
|
||||
),
|
||||
];
|
||||
if (!ticketIds.length) return;
|
||||
|
||||
await tx.commonSupportTicket.updateMany({
|
||||
where: { id: { in: ticketIds }, status: { not: 'REJECTED' } },
|
||||
data: {
|
||||
status: 'PUBLISHED',
|
||||
releasedVersionNo: versionNo,
|
||||
publishedAt: now,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async addVersionTasks(versionId: bigint, taskIds: string[]) {
|
||||
|
||||
if (!taskIds.length) return;
|
||||
|
||||
@@ -74,7 +74,7 @@ export class AdminStoresService {
|
||||
take: pageSize,
|
||||
include: {
|
||||
cityRef: { select: { id: true, name: true, code: true } },
|
||||
partnerAccount: { select: { id: true, companyName: true } },
|
||||
partnerAccount: { select: { id: true, companyName: true, name: true, phone: true } },
|
||||
category: { select: { id: true, name: true, parentId: true } },
|
||||
bindings: {
|
||||
where: { storeAccount: { isPrimary: 1 } },
|
||||
|
||||
@@ -6,6 +6,8 @@ import { PromoCodeService } from './promo-code.service';
|
||||
import {
|
||||
CreatePromoCodeDto,
|
||||
PromoCodeListQueryDto,
|
||||
PromoMetricEventsQueryDto,
|
||||
PromoMetricTimelineQueryDto,
|
||||
UpdatePromoCodeDto,
|
||||
UpdatePromoCodeStatusDto,
|
||||
} from './dto/promo-code.dto';
|
||||
@@ -39,6 +41,16 @@ export class AdminPromoCodeController {
|
||||
return this.service.stats(BigInt(id));
|
||||
}
|
||||
|
||||
@Get(':id/metrics/timeline')
|
||||
metricsTimeline(@Param('id') id: string, @Query() query: PromoMetricTimelineQueryDto) {
|
||||
return this.service.getMetricsTimeline(BigInt(id), query);
|
||||
}
|
||||
|
||||
@Get(':id/metrics/events')
|
||||
metricsEvents(@Param('id') id: string, @Query() query: PromoMetricEventsQueryDto) {
|
||||
return this.service.listMetricEvents(BigInt(id), query);
|
||||
}
|
||||
|
||||
@Get(':id/qrcode')
|
||||
qrcode(@Param('id') id: string) {
|
||||
return this.service.getQrcodeUrl(BigInt(id));
|
||||
|
||||
@@ -87,3 +87,37 @@ export class UpdatePromoCodeStatusDto {
|
||||
@IsIn(['ACTIVE', 'DISABLED'])
|
||||
status: PromoCodeStatus;
|
||||
}
|
||||
|
||||
export class PromoMetricTimelineQueryDto {
|
||||
@IsString()
|
||||
dateFrom: string;
|
||||
|
||||
@IsString()
|
||||
dateTo: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['day', 'hour'])
|
||||
granularity?: 'day' | 'hour';
|
||||
}
|
||||
|
||||
export class PromoMetricEventsQueryDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
pageSize?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['SCAN', 'ATTRIBUTION', 'REGISTER', 'ORDER'])
|
||||
eventType?: 'SCAN' | 'ATTRIBUTION' | 'REGISTER' | 'ORDER';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dateFrom?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dateTo?: string;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { randomBytes } from 'crypto';
|
||||
import {
|
||||
PROMO_CODE_SCENE_LABELS,
|
||||
PromoCodeScene,
|
||||
PromoMetricEventType,
|
||||
buildPromoLandingUrl,
|
||||
loadAppConfig,
|
||||
} from '@dukang/shared-types';
|
||||
@@ -16,12 +17,30 @@ import { serializeBigInt } from '../../common/decorators/current-user.decorator'
|
||||
import { OSS_PROVIDER, WECHAT_PROVIDER } from '../../integrations/integrations.constants';
|
||||
import type { IOssProvider } from '../../integrations/oss/oss.interface';
|
||||
import type { IWechatProvider } from '../../integrations/wechat/wechat.interface';
|
||||
import { PromoMetricLogService } from './promo-metric-log.service';
|
||||
import {
|
||||
computePromoMetricPeak,
|
||||
eachPromoMetricDay,
|
||||
eachPromoMetricHour,
|
||||
endOfDay,
|
||||
parsePromoMetricYmd,
|
||||
promoMetricBucketKey,
|
||||
promoMetricEventField,
|
||||
startOfDay,
|
||||
} from './promo-metric.util';
|
||||
import type {
|
||||
CreatePromoCodeDto,
|
||||
PromoCodeListQueryDto,
|
||||
PromoMetricEventsQueryDto,
|
||||
PromoMetricTimelineQueryDto,
|
||||
UpdatePromoCodeDto,
|
||||
} from './dto/promo-code.dto';
|
||||
|
||||
type PromoTouchMeta = {
|
||||
clientIp?: string;
|
||||
sessionId?: string;
|
||||
};
|
||||
|
||||
type PromoRow = {
|
||||
id: bigint;
|
||||
code: string;
|
||||
@@ -69,6 +88,7 @@ function maskPhone(phone: string | null | undefined) {
|
||||
export class PromoCodeService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly promoMetricLog: PromoMetricLogService,
|
||||
@Inject(OSS_PROVIDER) private readonly oss: IOssProvider,
|
||||
@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider,
|
||||
) {}
|
||||
@@ -88,7 +108,11 @@ export class PromoCodeService {
|
||||
}
|
||||
|
||||
/** 代下单绑定推广码:归因 + 用户来源(若可写) */
|
||||
async attributeUserToPromo(userId: bigint, promoId: bigint) {
|
||||
async attributeUserToPromo(
|
||||
userId: bigint,
|
||||
promoId: bigint,
|
||||
meta?: PromoTouchMeta,
|
||||
) {
|
||||
const promo = await this.prisma.commonPromoCode.findUnique({
|
||||
where: { id: promoId },
|
||||
select: { id: true, name: true, status: true },
|
||||
@@ -108,9 +132,13 @@ export class PromoCodeService {
|
||||
firstTouchAt: new Date(),
|
||||
},
|
||||
});
|
||||
this.logPromoMetric(promo.id, 'ATTRIBUTION', meta, { userId });
|
||||
}
|
||||
await this.applyPromoSourceToUser(userId, promo);
|
||||
return promo;
|
||||
const sourceApplied = await this.applyPromoSourceToUser(userId, promo, meta);
|
||||
if (sourceApplied) {
|
||||
this.logPromoMetric(promo.id, 'REGISTER', meta, { userId });
|
||||
}
|
||||
return { promo, sourceApplied };
|
||||
}
|
||||
|
||||
private mapOwnerUser(user: PromoRow['ownerUser']) {
|
||||
@@ -393,6 +421,7 @@ export class PromoCodeService {
|
||||
countScan?: boolean;
|
||||
},
|
||||
userId?: bigint,
|
||||
meta?: PromoTouchMeta,
|
||||
) {
|
||||
const promoCode = input.promoCode?.trim().toUpperCase();
|
||||
const qrcodeId = input.qrcodeId?.trim();
|
||||
@@ -433,7 +462,17 @@ export class PromoCodeService {
|
||||
attributed = true;
|
||||
}
|
||||
|
||||
sourceApplied = await this.applyPromoSourceToUser(userId, promo);
|
||||
sourceApplied = await this.applyPromoSourceToUser(userId, promo, meta);
|
||||
}
|
||||
|
||||
if (shouldCountScan) {
|
||||
this.logPromoMetric(promo.id, 'SCAN', meta, { userId });
|
||||
}
|
||||
if (attributed) {
|
||||
this.logPromoMetric(promo.id, 'ATTRIBUTION', meta, { userId });
|
||||
}
|
||||
if (sourceApplied) {
|
||||
this.logPromoMetric(promo.id, 'REGISTER', meta, { userId });
|
||||
}
|
||||
|
||||
return serializeBigInt({
|
||||
@@ -450,6 +489,7 @@ export class PromoCodeService {
|
||||
async applyPromoSourceToUser(
|
||||
userId: bigint,
|
||||
promo: { id: bigint; name: string },
|
||||
meta?: PromoTouchMeta,
|
||||
): Promise<boolean> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
@@ -470,6 +510,132 @@ export class PromoCodeService {
|
||||
return true;
|
||||
}
|
||||
|
||||
logPromoOrderEvent(
|
||||
promoCodeId: bigint,
|
||||
orderId: bigint,
|
||||
userId: bigint,
|
||||
clientIp?: string,
|
||||
): void {
|
||||
this.logPromoMetric(promoCodeId, 'ORDER', { clientIp }, { userId, orderId });
|
||||
}
|
||||
|
||||
private logPromoMetric(
|
||||
promoCodeId: bigint,
|
||||
eventType: PromoMetricEventType,
|
||||
meta: PromoTouchMeta | undefined,
|
||||
ids: { userId?: bigint; orderId?: bigint },
|
||||
): void {
|
||||
this.promoMetricLog.logEvent({
|
||||
promoCodeId,
|
||||
eventType,
|
||||
userId: ids.userId,
|
||||
orderId: ids.orderId,
|
||||
sessionId: meta?.sessionId,
|
||||
clientIp: meta?.clientIp,
|
||||
});
|
||||
}
|
||||
|
||||
async getMetricsTimeline(promoId: bigint, query: PromoMetricTimelineQueryDto) {
|
||||
await this.ensurePromoExists(promoId);
|
||||
|
||||
const granularity = query.granularity === 'hour' ? 'hour' : 'day';
|
||||
const from = parsePromoMetricYmd(query.dateFrom) ?? startOfDay(new Date());
|
||||
const toParsed = parsePromoMetricYmd(query.dateTo);
|
||||
const to = toParsed ? endOfDay(toParsed) : endOfDay(new Date());
|
||||
|
||||
const keys =
|
||||
granularity === 'hour'
|
||||
? eachPromoMetricHour(from, to)
|
||||
: eachPromoMetricDay(from, to);
|
||||
|
||||
const bucketMap = new Map(
|
||||
keys.map((key) => [key, { key, scan: 0, attribution: 0, register: 0, order: 0 }]),
|
||||
);
|
||||
|
||||
const rows = await this.prisma.logPromoEvent.findMany({
|
||||
where: {
|
||||
promoCodeId: promoId,
|
||||
createdAt: { gte: from, lte: to },
|
||||
},
|
||||
select: { eventType: true, createdAt: true },
|
||||
});
|
||||
|
||||
for (const row of rows) {
|
||||
const key = promoMetricBucketKey(row.createdAt, granularity);
|
||||
const bucket = bucketMap.get(key);
|
||||
if (!bucket) continue;
|
||||
bucket[promoMetricEventField(row.eventType)] += 1;
|
||||
}
|
||||
|
||||
const buckets = keys.map((key) => bucketMap.get(key)!);
|
||||
return serializeBigInt({
|
||||
granularity,
|
||||
dateFrom: query.dateFrom,
|
||||
dateTo: query.dateTo,
|
||||
buckets,
|
||||
peak: computePromoMetricPeak(buckets),
|
||||
});
|
||||
}
|
||||
|
||||
async listMetricEvents(promoId: bigint, query: PromoMetricEventsQueryDto) {
|
||||
await this.ensurePromoExists(promoId);
|
||||
|
||||
const page = query.page && query.page > 0 ? query.page : 1;
|
||||
const pageSize = query.pageSize && query.pageSize > 0 ? Math.min(query.pageSize, 100) : 20;
|
||||
|
||||
const from = query.dateFrom ? parsePromoMetricYmd(query.dateFrom) : undefined;
|
||||
const toParsed = query.dateTo ? parsePromoMetricYmd(query.dateTo) : undefined;
|
||||
const to = toParsed ? endOfDay(toParsed) : undefined;
|
||||
|
||||
const where = {
|
||||
promoCodeId: promoId,
|
||||
...(query.eventType ? { eventType: query.eventType } : {}),
|
||||
...(from || to
|
||||
? {
|
||||
createdAt: {
|
||||
...(from ? { gte: from } : {}),
|
||||
...(to ? { lte: to } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.logPromoEvent.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.logPromoEvent.count({ where }),
|
||||
]);
|
||||
|
||||
return serializeBigInt({
|
||||
items: items.map((row) => ({
|
||||
id: row.id,
|
||||
eventType: row.eventType,
|
||||
userId: row.userId,
|
||||
orderId: row.orderId,
|
||||
sessionId: row.sessionId,
|
||||
clientIp: row.clientIp,
|
||||
ipProvince: row.ipProvince,
|
||||
ipCity: row.ipCity,
|
||||
createdAt: row.createdAt,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
private async ensurePromoExists(promoId: bigint) {
|
||||
const promo = await this.prisma.commonPromoCode.findUnique({
|
||||
where: { id: promoId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!promo) throw new NotFoundException('推广码不存在');
|
||||
}
|
||||
|
||||
private async statsFromRow(row: { id: bigint; scanCount: number; orderCount: number }) {
|
||||
const scanCount = row.scanCount;
|
||||
const orderCount = row.orderCount;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PromoMetricEventType } from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { IpGeoService } from '../../common/geo/ip-geo.service';
|
||||
|
||||
export type PromoMetricLogInput = {
|
||||
promoCodeId: bigint;
|
||||
eventType: PromoMetricEventType;
|
||||
userId?: bigint;
|
||||
orderId?: bigint;
|
||||
sessionId?: string;
|
||||
clientIp?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PromoMetricLogService {
|
||||
private readonly logger = new Logger(PromoMetricLogService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly ipGeoService: IpGeoService,
|
||||
) {}
|
||||
|
||||
logEvent(input: PromoMetricLogInput): void {
|
||||
void this.writeEvent(input).catch((err) => {
|
||||
this.logger.debug(
|
||||
`promo metric log failed: ${err instanceof Error ? err.message : err}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private async writeEvent(input: PromoMetricLogInput): Promise<void> {
|
||||
const clientIp = input.clientIp?.trim() || null;
|
||||
const geo = clientIp ? this.ipGeoService.resolve(clientIp) : { province: null, city: null };
|
||||
|
||||
await this.prisma.logPromoEvent.create({
|
||||
data: {
|
||||
promoCodeId: input.promoCodeId,
|
||||
eventType: input.eventType,
|
||||
userId: input.userId ?? null,
|
||||
orderId: input.orderId ?? null,
|
||||
sessionId: input.sessionId?.trim() || null,
|
||||
clientIp,
|
||||
ipProvince: geo.province,
|
||||
ipCity: geo.city,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { PromoMetricEventType, PromoMetricTimelinePeak } from '@dukang/shared-types';
|
||||
|
||||
function startOfDay(d: Date) {
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
function endOfDay(d: Date) {
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999);
|
||||
}
|
||||
|
||||
export function parsePromoMetricYmd(s: string): Date | null {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return null;
|
||||
const d = new Date(`${s}T00:00:00`);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
export function formatPromoMetricYmd(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
export function formatPromoMetricHour(d: Date): string {
|
||||
const ymd = formatPromoMetricYmd(d);
|
||||
const h = String(d.getHours()).padStart(2, '0');
|
||||
return `${ymd} ${h}:00`;
|
||||
}
|
||||
|
||||
export function eachPromoMetricDay(from: Date, to: Date): string[] {
|
||||
const out: string[] = [];
|
||||
const cur = startOfDay(from);
|
||||
const end = startOfDay(to);
|
||||
while (cur <= end) {
|
||||
out.push(formatPromoMetricYmd(cur));
|
||||
cur.setDate(cur.getDate() + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function eachPromoMetricHour(from: Date, to: Date): string[] {
|
||||
const out: string[] = [];
|
||||
const cur = new Date(from);
|
||||
cur.setMinutes(0, 0, 0);
|
||||
const end = endOfDay(to);
|
||||
while (cur <= end) {
|
||||
out.push(formatPromoMetricHour(cur));
|
||||
cur.setHours(cur.getHours() + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function promoMetricBucketKey(
|
||||
createdAt: Date,
|
||||
granularity: 'day' | 'hour',
|
||||
): string {
|
||||
return granularity === 'day'
|
||||
? formatPromoMetricYmd(createdAt)
|
||||
: formatPromoMetricHour(createdAt);
|
||||
}
|
||||
|
||||
export function promoMetricEventField(
|
||||
eventType: PromoMetricEventType,
|
||||
): 'scan' | 'attribution' | 'register' | 'order' {
|
||||
switch (eventType) {
|
||||
case 'SCAN':
|
||||
return 'scan';
|
||||
case 'ATTRIBUTION':
|
||||
return 'attribution';
|
||||
case 'REGISTER':
|
||||
return 'register';
|
||||
case 'ORDER':
|
||||
return 'order';
|
||||
default:
|
||||
return 'scan';
|
||||
}
|
||||
}
|
||||
|
||||
export function computePromoMetricPeak(
|
||||
buckets: Array<{
|
||||
key: string;
|
||||
scan: number;
|
||||
attribution: number;
|
||||
register: number;
|
||||
order: number;
|
||||
}>,
|
||||
): PromoMetricTimelinePeak {
|
||||
const pick = (field: 'scan' | 'attribution' | 'register' | 'order') => {
|
||||
let best: { key: string; count: number } | null = null;
|
||||
for (const b of buckets) {
|
||||
const count = b[field];
|
||||
if (!best || count > best.count) {
|
||||
best = { key: b.key, count };
|
||||
}
|
||||
}
|
||||
return best && best.count > 0 ? best : null;
|
||||
};
|
||||
return {
|
||||
scan: pick('scan'),
|
||||
attribution: pick('attribution'),
|
||||
register: pick('register'),
|
||||
order: pick('order'),
|
||||
};
|
||||
}
|
||||
|
||||
export { startOfDay, endOfDay };
|
||||
@@ -1,13 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { GeoModule } from '../../common/geo/geo.module';
|
||||
import { IntegrationsModule } from '../../integrations/integrations.module';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import { AdminPromoCodeController } from './admin-promo-code.controller';
|
||||
import { PromoCodeService } from './promo-code.service';
|
||||
import { PromoMetricLogService } from './promo-metric-log.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
GeoModule,
|
||||
IntegrationsModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET || 'dukang-prev1-dev-secret',
|
||||
@@ -15,7 +18,7 @@ import { PromoCodeService } from './promo-code.service';
|
||||
}),
|
||||
],
|
||||
controllers: [AdminPromoCodeController],
|
||||
providers: [PromoCodeService, JwtAuthGuard, HqAuthGuard],
|
||||
providers: [PromoCodeService, PromoMetricLogService, JwtAuthGuard, HqAuthGuard],
|
||||
exports: [PromoCodeService],
|
||||
})
|
||||
export class PromoModule {}
|
||||
|
||||
@@ -272,6 +272,15 @@ export class TradeService {
|
||||
return created;
|
||||
});
|
||||
|
||||
if (promoCodeId) {
|
||||
this.promoCodeService.logPromoOrderEvent(
|
||||
promoCodeId,
|
||||
order.id,
|
||||
userId,
|
||||
location.clientIp ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
this.analyticsService.trackOneSafe(userId, 'USER_H5', {
|
||||
eventName: 'order_submit',
|
||||
refType: 'ORDER',
|
||||
@@ -1571,12 +1580,6 @@ export class TradeService {
|
||||
|
||||
const paySnapshot = await this.partnerCityService.resolveForOrder(city.id, commissionDistrict);
|
||||
|
||||
let promoCodeId: bigint | undefined;
|
||||
if (body.promoCodeId?.trim()) {
|
||||
promoCodeId = BigInt(body.promoCodeId.trim());
|
||||
await this.promoCodeService.attributeUserToPromo(user.id, promoCodeId);
|
||||
}
|
||||
|
||||
const orderNo = generateOrderNo();
|
||||
const payExpireAt = new Date(Date.now() + 30 * 60 * 1000);
|
||||
const location = buildOrderClientLocationSnapshot(
|
||||
@@ -1585,6 +1588,14 @@ export class TradeService {
|
||||
undefined,
|
||||
);
|
||||
|
||||
let promoCodeId: bigint | undefined;
|
||||
if (body.promoCodeId?.trim()) {
|
||||
promoCodeId = BigInt(body.promoCodeId.trim());
|
||||
await this.promoCodeService.attributeUserToPromo(user.id, promoCodeId, {
|
||||
clientIp: location.clientIp ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const order = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.order.create({
|
||||
data: {
|
||||
@@ -1651,6 +1662,15 @@ export class TradeService {
|
||||
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',
|
||||
@@ -1905,12 +1925,6 @@ export class TradeService {
|
||||
|
||||
const paySnapshot = await this.partnerCityService.resolveForOrder(city.id, commissionDistrict);
|
||||
|
||||
let promoCodeId: bigint | undefined;
|
||||
if (body.promoCodeId?.trim()) {
|
||||
promoCodeId = BigInt(body.promoCodeId.trim());
|
||||
await this.promoCodeService.attributeUserToPromo(user.id, promoCodeId);
|
||||
}
|
||||
|
||||
const orderNo = generateOrderNo();
|
||||
const payExpireAt = new Date(Date.now() + 30 * 60 * 1000);
|
||||
const location = buildOrderClientLocationSnapshot(
|
||||
@@ -1919,6 +1933,14 @@ export class TradeService {
|
||||
undefined,
|
||||
);
|
||||
|
||||
let promoCodeId: bigint | undefined;
|
||||
if (body.promoCodeId?.trim()) {
|
||||
promoCodeId = BigInt(body.promoCodeId.trim());
|
||||
await this.promoCodeService.attributeUserToPromo(user.id, promoCodeId, {
|
||||
clientIp: location.clientIp ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const order = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.order.create({
|
||||
data: {
|
||||
@@ -1985,6 +2007,15 @@ export class TradeService {
|
||||
return created;
|
||||
});
|
||||
|
||||
if (promoCodeId) {
|
||||
this.promoCodeService.logPromoOrderEvent(
|
||||
promoCodeId,
|
||||
order.id,
|
||||
user.id,
|
||||
location.clientIp ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: order.id.toString(),
|
||||
orderNo: order.orderNo,
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@
|
||||
|
||||
| 版本 | 日期 | 说明 |
|
||||
|------|------|------|
|
||||
| **3.4.13** | 2026-08-05 | 推广码归因统计、核销用户信息、技术支持工单优先级、mini-user 门店/商品/提货/版本/**物流增强(签收照/拨号/ETA/路由回调签收→已完成)**、H5 登录校验、合伙人微信暂停禁登、**OSS 图片超 10MB 客户端压缩**;开发设计见 [`杜康好客-v3.4.13-体验优化开发文档.md`](./杜康好客-v3.4.13-体验优化开发文档.md) |
|
||||
| **3.4.13** | 2026-08-05 | 推广码归因统计 + **指标事件日志/高峰趋势**、核销用户信息 + **权益券详情加宽/核销详情增强**、技术支持工单优先级、mini-user **我的页版本号/低于 min 强制退出**、门店/商品/提货/**物流增强(签收照/拨号/ETA/路由回调签收→已完成)**、H5 登录校验、合伙人微信暂停禁登、**OSS 图片超 10MB 客户端压缩**;开发设计见 [`杜康好客-v3.4.13-体验优化开发文档.md`](./杜康好客-v3.4.13-体验优化开发文档.md) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+2
-2
@@ -334,12 +334,12 @@ C2~C7、C14 见 §1.3。
|
||||
|
||||
| 项 | 状态 | 说明 |
|
||||
|----|------|------|
|
||||
| 推广码 attributionCount | ✅ | HQ 详情统计卡 |
|
||||
| 推广码 attributionCount | ✅ | HQ 详情统计卡 + **log_promo_event 事件日志 + metrics/timeline ECharts** |
|
||||
| 核销用户信息 | ✅ | RedeemRecordsPage 列表+详情 |
|
||||
| 技术支持优先级 | ✅ | Prisma + shared-types + HQ UI |
|
||||
| 合伙人微信暂停禁登 | ✅ | loginPartnerWechat |
|
||||
| H5 登录前端校验 | ✅ | partner/shop LoginPage |
|
||||
| mini-user 门店体验 | ✅ | 电话脱敏/埋点、门头 aspectFit、套餐折叠 |
|
||||
| mini-user 门店体验 | ✅ | 电话脱敏/埋点、**列表两段营业时间**、门头 aspectFill 铺满、**套餐页签切换** |
|
||||
| mini-user 商品/提货 | ✅ | 去分享、首图 preview、提货确认弹框 |
|
||||
| mini-user 版本/物流 | ✅ | 摘要 + 签收照 + 拨号 + 时间线 + ETA;回调签收→**COMPLETED** |
|
||||
| OSS 大图压缩 | ✅ | shared-ui compressImage;四端 upload + mini 头像 |
|
||||
|
||||
+55
-20
@@ -7,33 +7,33 @@
|
||||
|
||||
| 模块 | 内容 |
|
||||
|------|------|
|
||||
| admin-web | 推广码 attributionCount;核销记录用户信息;技术支持工单优先级;**大图上传前压缩** |
|
||||
| mini-user | 门店电话脱敏+拨打埋点;门头/套餐展示;商品去分享+首图 preview;提货确认弹框;版本更新提示;**物流增强(签收照/拨号/时间线/ETA)**;**头像超 10MB 压缩** |
|
||||
| h5-partner / h5-shop / h5-user | 登录 phone/code 前端校验;**OSS 图片超 10MB 自动 canvas 压缩后上传** |
|
||||
| 后端 | 工单 priority;client-config minClientVersion;合伙人微信暂停禁登;**Courier 适配器(100108/100301/路由回调)** |
|
||||
| admin-web | 推广码 attributionCount + **指标事件日志/ECharts 趋势**;**权益券详情加宽**;**核销记录详情增强(对齐权益券内核销详情)**;技术支持工单优先级;**版本发布联动任务/工单已发布**;**大图上传前压缩** |
|
||||
| mini-user | 门店电话脱敏+拨打埋点;**门店列表展示两段营业时间**;**门店详情门头 aspectFill 铺满无留白**;**门店套餐页签切换**;商品去分享+首图 preview;提货确认弹框;**我的页版本号 v3.4.x**;**低于 minClientVersion 强制更新/点我知道了退出**;**物流增强(签收照/拨号/时间线/ETA)**;**头像超 10MB 压缩** |
|
||||
| h5-partner / h5-shop / h5-user | 登录 phone/code 前端校验;**门店端 iOS 首次扫码 OAuth 单例 + JSSDK 预热重试**;**OSS 图片超 10MB 自动 canvas 压缩后上传** |
|
||||
| 后端 | 工单 priority;**工单 PUBLISHED + releasedVersionNo**;**版本 RELEASED 联动任务/工单**;client-config minClientVersion;合伙人微信暂停禁登;**Courier 适配器**;**log_promo_event 推广码指标日志** |
|
||||
|
||||
## 2. ST 映射
|
||||
|
||||
| ST | 标题 | 状态 |
|
||||
|----|------|------|
|
||||
| ST1785925037781309 | 总部端-推广码数据跟踪优化 | ✅ attributionCount 统计卡 |
|
||||
| ST1785925037781309 | 总部端-推广码数据跟踪优化 | ✅ attributionCount + **事件日志 + ECharts 趋势** |
|
||||
| ST1785924286682833 | 用户端-门店电话加密+拨打埋点 | ✅ maskPhone + store_phone_call |
|
||||
| ST1785921693982470 | 技术支持-工单优先级 | ✅ priority 枚举 + HQ UI |
|
||||
| ST1785921585900725 | 门店端扫一扫授权异常 | ✅ 已有(v3.4.12 前) |
|
||||
| ST1785907536648201 | 版本不对提示更新 | ✅ UpdateManager + minClientVersion |
|
||||
| ST1785906800359657 | 合伙人暂停后禁登 | ✅ loginPartnerWechat status |
|
||||
| ST1785921585900725 | 门店端扫一扫授权异常 | ✅ **OAuth 单例回调 + iOS JSSDK 预热/重试 + 授权后续扫** |
|
||||
| ST1785907536648201 | 版本不对提示更新 | ✅ 我的页 v3.4.x + UpdateManager + **低于 min 强制更新/退出** |
|
||||
| ST1785906800359657 | 合伙人暂停后禁登 | ✅ **发码前 phone/check + 微信/SMS 登录均拒 DISABLED** |
|
||||
| ST1785906592340255 | PARTNER_H5 login 校验 | ✅ 前端空字段拦截 |
|
||||
| ST1785906390321653 | 现场提货提交确认弹框 | ✅ showModal |
|
||||
| ST1785905773501871 | 核销记录用户信息 | ✅ 列表+详情 |
|
||||
| ST1785905773501871 | 核销记录用户信息 | ✅ 列表+详情;**详情含门店/合伙人/券分摊/结算/评价** |
|
||||
| ST1785904849234806 | 工单中心 | ✅ 已有 |
|
||||
| ST1785904076632841 | 门店列表开城合伙人 | ✅ 已有 |
|
||||
| ST1785904076632841 | 门店列表开城合伙人 | ✅ **列表展示 companyName/name/phone(同 partnerOptionLabel)** |
|
||||
| ST1785902173093977 | 去掉商品详情分享按钮 | ✅ 移除 ShareNavButton |
|
||||
| ST1785902141731113 | 商品首图大图 | ✅ previewable |
|
||||
| ST1785901870913349 | 门店列表营业时间 | ✅ 已有 |
|
||||
| ST1785901870913349 | 门店列表营业时间 | ✅ **列表展示两段营业时间** |
|
||||
| ST1785901838948572 | SHOP 未绑定门店 | ✅ 已有 |
|
||||
| ST1785901775231811 | 门店套餐遮挡 | ✅ 折叠/行数限制 |
|
||||
| ST1785901775231811 | 门店套餐遮挡 | ✅ **页签切换(可横滑)+ 紧凑内容区** |
|
||||
| ST1785901711627824 | SHOP login 校验 | ✅ 前端空字段拦截 |
|
||||
| ST1785901314893145 | 门头照裁剪 | ✅ aspectFit + preview |
|
||||
| ST1785901314893145 | 门头照裁剪 | ✅ aspectFill 铺满无留白 + preview |
|
||||
| ST1785939375449985 | 订单物流追踪页 | ✅ 签收照 + 拨号 + 时间线 + ETA + 路由回调 |
|
||||
| — | 上传图片超 10MB 先压缩 | ✅ `@dukang/shared-ui/compressImage` + 各端 upload |
|
||||
|
||||
@@ -41,13 +41,41 @@
|
||||
|
||||
| 方法 | 路径 / 配置 | 说明 |
|
||||
|------|-------------|------|
|
||||
| GET | `/common/client-config` | 新增 `minClientVersion`(env `MINI_USER_MIN_VERSION`) |
|
||||
| GET | `/common/client-config` | `minClientVersion`(`MINI_USER_MIN_VERSION`);客户端 semver 比对,低于则强制更新或退出 |
|
||||
| — | `CommonSupportTicket.priority` | `LOW \| NORMAL \| HIGH \| URGENT`,默认 NORMAL |
|
||||
| — | `CommonSupportTicket.status` | 新增 `PUBLISHED`(已发布);`releasedVersionNo` + `publishedAt` |
|
||||
| — | `DevPlanVersion` → `RELEASED` | 关联任务 → `RELEASED`;关联工单 → `PUBLISHED` 并写入版本号 |
|
||||
| — | `loginPartnerWechat` | 非 ACTIVE 账号抛出「合伙人账号已停用」 |
|
||||
| GET | `/trade/orders/:id/track` | 聚合:`nodes`(旧→新)、`signPhotoUrls`、`estimatedArrival`;经 `CourierService` 适配小飞侠 cmd 100102/100108/100301 |
|
||||
| POST | `/callbacks/courier/xfx/track` | 小飞侠路由变化回调(适配器入口) |
|
||||
| POST | `/callbacks/courier/logistics/track` | 跨城物流回调占位(记录日志,后续接入) |
|
||||
| POST | `/callbacks/delivery/track` | 兼容旧路径,等同 `xfx` |
|
||||
| GET | `/admin/promo-codes/:id/metrics/timeline` | 推广码四指标时间序列(按日/按时 + peak) |
|
||||
| GET | `/admin/promo-codes/:id/metrics/events` | 推广码指标事件分页日志(时间/ID/IP/地点) |
|
||||
|
||||
### 3.1 推广码指标日志(ST1785925037781309)
|
||||
|
||||
**表** `log_promo_event`
|
||||
|
||||
| event_type | 统计卡 | 写入时机 | ID |
|
||||
|------------|--------|----------|-----|
|
||||
| `SCAN` | 扫码进入数 | `POST /promo/touch` 且 `countScan !== false` | userId / sessionId |
|
||||
| `ATTRIBUTION` | 归因用户数 | 首次写入 `user_promo_attribution` | userId |
|
||||
| `REGISTER` | 扫码注册用户数 | `applyPromoSourceToUser` 成功 | userId |
|
||||
| `ORDER` | 订单数 | 带推广码下单 `orderCount++` | orderId + userId |
|
||||
|
||||
每条日志含 `created_at`、可选 ID、`client_ip`、`ip_province`/`ip_city`。仅统计**上线后**新事件;累计 Statistic 卡逻辑不变。
|
||||
|
||||
**HQ UI**:推广码详情页 → 数据趋势 Card(DatePicker + 按日/按时 + ECharts 四曲线 + 事件 Table)
|
||||
|
||||
### 3.2 mini-user 版本门控(ST1785907536648201)
|
||||
|
||||
| 项 | 行为 |
|
||||
|----|------|
|
||||
| 我的页 | 左下角展示 `杜康好客 v3.4.x`(与 `APP_VERSION` / package.json 同步) |
|
||||
| 启动校验 | `GET /common/client-config` → `minClientVersion`;`APP_VERSION` 低于最低版本时拦截 |
|
||||
| 微信有新包 | `UpdateManager` 弹「立即更新」→ `applyUpdate()` |
|
||||
| 无新包 / 仍过低 | 弹「版本过低」→ 点「我知道了」→ `Taro.exitMiniProgram()` 退出小程序 |
|
||||
|
||||
### 4. mini-user 物流(ST1785939375449985)
|
||||
|
||||
@@ -109,19 +137,26 @@ POST https://api-test.dukanghaoke.com/api/v1/callbacks/courier/xfx/track
|
||||
## 5. 数据表
|
||||
|
||||
- `common_support_ticket.priority` ENUM,默认 `NORMAL`
|
||||
- `common_support_ticket.status` 新增 `PUBLISHED`;`released_version_no`、`published_at`
|
||||
- `log_promo_event`:推广码指标事件(`promo_code_id`, `event_type`, `user_id`, `order_id`, `session_id`, `client_ip`, 地点, `created_at`)
|
||||
|
||||
## 6. HQ 开发计划
|
||||
|
||||
在 admin-web **开发计划 → 版本列表** 创建 `v3.4.13`(状态 `IN_PROGRESS`),审批 ST 后关联 `dev_plan_task`。
|
||||
在 admin-web **开发计划 → 版本列表** 创建 `v3.4.13`(状态 `IN_PROGRESS`),审批 ST 后关联 `dev_plan_task`。**版本标记为「已发布」后**,该版本下任务自动 `RELEASED`,关联技术支持工单自动 `PUBLISHED` 并写入 `releasedVersionNo`。
|
||||
|
||||
## 7. 验收 ACC
|
||||
|
||||
- [ ] 推广码详情展示 attributionCount
|
||||
- [ ] 核销记录含 userNo/nickname/phone
|
||||
- [ ] 技术支持可创建/筛选/编辑优先级
|
||||
- [ ] 合伙人/门店登录空字段前端提示;暂停合伙人微信登录被拒
|
||||
- [ ] mini-user:电话脱敏、拨打埋点、提货确认、无分享按钮、首图 preview、门头/套餐正常
|
||||
- [ ] 推广码详情展示 attributionCount;**四指标事件日志 + ECharts 按日/按时趋势 + 高峰标注**
|
||||
- [ ] 推广码 SCAN/ATTRIBUTION/REGISTER/ORDER 事件含 time + IP;幂等(重复 touch 不计 SCAN)
|
||||
- [ ] 核销记录含 userNo/nickname/phone;**详情抽屉展示门店/地址/合伙人/权益券号/关联订单/券分摊/结算单/评价**
|
||||
- [ ] **权益券详情抽屉加宽(980px),内嵌核销列表无横向滚动条**
|
||||
- [ ] 技术支持可创建/筛选/编辑优先级;**版本 RELEASED 后关联工单为「已发布」并带版本号**
|
||||
- [ ] **门店列表「开城合伙人」列展示 companyName / name / phone(与下拉选项一致)**
|
||||
- [ ] 合伙人/门店登录空字段前端提示;**暂停合伙人发码前即拦截,短信/微信登录均禁止**
|
||||
- [ ] mini-user:我的页左下角显示 `杜康好客 v3.4.x`;低于 `minClientVersion` 时强制更新,无新包则点「我知道了」退出小程序
|
||||
- [ ] mini-user:电话脱敏、拨打埋点、提货确认、无分享按钮、首图 preview、**门店列表两段营业时间**、**门店详情门头铺满无留白**、**门店套餐页签切换**
|
||||
- [ ] mini-user:配送中物流摘要 + 签收照预览 + 电话拨号 + 时间线旧→新全点亮 + 预估送达
|
||||
- [ ] 小飞侠路由回调 `POST /callbacks/courier/xfx/track`:status=5 或 statusName 已签收 → 订单 **COMPLETED**;重复回调幂等
|
||||
- [ ] 各端上传 >10MB 图片自动压缩后可上传;仍超限有明确报错
|
||||
- [ ] **门店 H5(iPhone 微信)**:首次扫码 OAuth 后自动续扫;offline verifying 时提示再点一次扫码
|
||||
- [ ] `pnpm lint` 无新增错误
|
||||
|
||||
Reference in New Issue
Block a user