58 Commits

Author SHA1 Message Date
jacy 0181af09c5 merge(dev): v3.4.13 partner column, release cascade, client UX
CI / verify (push) Has been cancelled
2026-08-06 01:02:29 +08:00
jacy 9cd0c9508a merge(dev_jacy): v3.4.13 partner column, release cascade, client UX 2026-08-06 01:02:15 +08:00
jacy 4f363fa9e7 feat(v3.4.13): partner column, version release cascade, client UX fixes
- HQ stores: partner column uses companyName/name/phone via partnerOptionLabel

- Dev plan: version RELEASED auto-marks tasks RELEASED and tickets PUBLISHED with releasedVersionNo

- mini-user/h5-shop/h5-partner v3.4.13 UX fixes (store UI, iOS scan OAuth, partner login guard)

- Docs: v3.4.13 dev doc + status audit updates

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 01:02:07 +08:00
jacy 291137a15d merge(dev): admin redeem detail UI (v3.4.13)
CI / verify (push) Has been cancelled
2026-08-06 00:27:21 +08:00
jacy e2a5f21f9d merge(dev_jacy): admin redeem detail UI (v3.4.13) 2026-08-06 00:27:18 +08:00
jacy 3ef4432b2f feat(admin-web): widen benefit coupon detail and enrich redeem record drawer (v3.4.13)
Share redeem detail fields across benefit coupon and redeem records pages;
widen coupon drawer to avoid horizontal scroll.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 00:27:11 +08:00
jacy 33b868dfac merge(dev): ticket center status Chinese labels
CI / verify (push) Has been cancelled
2026-08-06 00:23:43 +08:00
jacy 5c4ad1d8db merge(dev_jacy): ticket center status Chinese labels 2026-08-06 00:23:41 +08:00
jacy a346fae7a4 fix(admin-web): show Chinese labels for ticket center status
Add TICKET_STATUS_LABELS and use Tag in list, detail and filter on TicketsPage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 00:23:33 +08:00
jacy 37bc297d1a merge(dev): mini-user version display and force exit (v3.4.13)
CI / verify (push) Has been cancelled
2026-08-06 00:15:06 +08:00
jacy bc1616c3f7 merge(dev_jacy): mini-user version display and force exit (v3.4.13) 2026-08-06 00:15:03 +08:00
jacy 46a200e0ba feat(mini-user): show version on mine page and force exit when below min (v3.4.13)
Display v3.4.x next to brand on mine page; below minClientVersion prompt update via UpdateManager or exit mini program after acknowledge.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 00:14:53 +08:00
jacy 429eaabe6b merge(dev): promo metric logs and HQ timeline (v3.4.13)
CI / verify (push) Has been cancelled
2026-08-06 00:07:02 +08:00
jacy 456b3af461 merge(dev): courier signed callback completes order (v3.4.13)
CI / verify (push) Has been cancelled
2026-08-05 23:46:47 +08:00
jacy 69f52921a7 merge(dev): v3.4.13 logistics enhancements
CI / verify (push) Has been cancelled
2026-08-05 23:34:20 +08:00
jacy 7c9240fc84 merge(dev): logistics detail page polish (v3.4.13)
CI / verify (push) Has been cancelled
2026-08-05 23:14:28 +08:00
jacy 9d49ed5e70 merge(dev): mini-user logistics preview and detail timeline (v3.4.13)
CI / verify (push) Has been cancelled
2026-08-05 23:03:58 +08:00
jacy d0ab838da6 merge(dev): v3.4.13 upload compression and winery T+3 fix
CI / verify (push) Has been cancelled
2026-08-05 22:48:47 +08:00
jacy dc4295d1bd merge(dev): v3.4.13 experience optimizations
CI / verify (push) Has been cancelled
2026-08-05 22:34:44 +08:00
jacy bf501d2374 merge(dev): support ticket batch select and task bind
CI / verify (push) Has been cancelled
2026-08-05 08:08:27 +08:00
jacy b40eb83f27 Merge pull request 'Dev' (#13) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/13
2026-08-05 00:57:33 +08:00
jacy f5e523a425 merge(dev): mini-user store list card redesign
CI / verify (push) Has been cancelled
2026-08-05 00:11:34 +08:00
jacy b21403a47c merge(dev): kb doc edit, validation auto-tickets, support batch ops
CI / verify (push) Has been cancelled
2026-08-05 00:09:07 +08:00
jacy f9a5b6e81a merge(dev): fix h5-shop upload export for v3.4.12
CI / verify (push) Has been cancelled
2026-08-04 23:54:22 +08:00
jacy 1c787ed576 merge(dev): release v3.4.12
CI / verify (push) Has been cancelled
2026-08-04 23:53:48 +08:00
jacy aca3df4bf2 merge(dev): release v3.4.11 dev plan, wecom push and bot fixes
CI / verify (push) Has been cancelled
2026-08-04 22:41:49 +08:00
jacy fa0d928980 merge(dev): release v3.4.10
CI / verify (push) Has been cancelled
2026-08-03 22:54:23 +08:00
jacy 6f427cb553 merge(dev): persona logging and Sentry system config
CI / verify (push) Has been cancelled
2026-08-03 22:31:27 +08:00
jacy a7d55b7293 merge(dev): 走马灯滚出终点调整
CI / verify (push) Has been cancelled
2026-08-03 17:52:18 +08:00
jacy f09e00e16f merge(dev): 门店核销走马灯优化
CI / verify (push) Has been cancelled
2026-08-03 16:34:36 +08:00
jacy aa0e140fec merge(dev): 核销方式标记与统计
CI / verify (push) Has been cancelled
2026-08-03 16:21:47 +08:00
jacy f8d8e2d6d5 merge(dev): 统一门店结算账单
CI / verify (push) Has been cancelled
2026-08-03 16:04:55 +08:00
jacy 9859abba7e merge(dev): 全部门店可提现+企微提醒
CI / verify (push) Has been cancelled
2026-08-03 15:43:34 +08:00
jacy c89560e4f8 merge(dev): 门店手动提现 OPT-010
CI / verify (push) Has been cancelled
2026-08-03 15:32:17 +08:00
jacy ffed560d43 merge(dev): 小程序 Intl 兜底
CI / verify (push) Has been cancelled
2026-08-03 14:54:02 +08:00
jacy 5199b495e0 merge(dev): 门店核销走马灯兼容 H5/小程序
CI / verify (push) Has been cancelled
2026-08-03 14:48:41 +08:00
jacy 82b98aa4e3 merge(dev): 修复 Intl is not defined
CI / verify (push) Has been cancelled
2026-08-03 14:37:48 +08:00
jacy 7dffd40f3f merge(dev): 走马灯 JS 实现
CI / verify (push) Has been cancelled
2026-08-03 14:33:35 +08:00
jacy 0304201e80 merge(dev): 发版默认生产约定
CI / verify (push) Has been cancelled
2026-08-03 14:29:55 +08:00
jacy dd4362c397 merge(dev): 门店详情修复
CI / verify (push) Has been cancelled
2026-08-03 14:29:03 +08:00
jacy 87515decb3 merge(dev): 核销时间格式与门店走马灯
CI / verify (push) Has been cancelled
2026-08-03 14:20:36 +08:00
jacy 28ef916a22 merge(dev): 门店可见白名单
CI / verify (push) Has been cancelled
2026-08-03 13:58:47 +08:00
jacy 39e38aac7e merge(dev): mini-user eight fixes and catalog whitelist
CI / verify (push) Has been cancelled
2026-08-03 13:12:40 +08:00
jacy daf534b4db merge(dev): skip WeCom audit client-error noise
CI / verify (push) Has been cancelled
2026-08-02 21:52:18 +08:00
jacy 118993a01f merge(dev): deploy default DB sync
CI / verify (push) Has been cancelled
2026-08-02 18:12:37 +08:00
jacy 8f361981bf merge(dev): product fulfillment flags and auto SKU
CI / verify (push) Has been cancelled
2026-08-02 18:10:31 +08:00
jacy 4bb6ae20cd merge(dev): fix partner image picker after cancel
CI / verify (push) Has been cancelled
2026-08-02 17:11:31 +08:00
jacy ad757e9ba8 merge(dev): restore camera and fix mine benefit-detail back
CI / verify (push) Has been cancelled
2026-08-02 17:00:58 +08:00
jacy e63b57db19 merge(dev): client error reporting
CI / verify (push) Has been cancelled
2026-08-02 16:27:20 +08:00
jacy fb4432b307 merge(dev): WeCom webhook ops alerts
CI / verify (push) Has been cancelled
2026-08-02 16:02:35 +08:00
jacy ab44515e8b merge(dev_jacy): on-site pickup COMPLETED on pay
CI / verify (push) Has been cancelled
2026-08-02 13:42:17 +08:00
jacy 78365d648c merge(dev_jacy): proxy-order real pay
CI / verify (push) Has been cancelled
2026-08-02 13:28:16 +08:00
jacy e9eddbb26e merge(dev_jacy): shop WeChat unbound tip
CI / verify (push) Has been cancelled
2026-08-02 13:15:50 +08:00
jacy c00ca3620d merge(dev): partner dropdown label company-name
CI / verify (push) Has been cancelled
2026-08-02 13:07:41 +08:00
jacy 4d76ee6d0e merge(dev): WeChat login restore, proxy pay, staging SSL verify
CI / verify (push) Has been cancelled
2026-08-02 12:53:53 +08:00
jacy 2dbe665bdc Merge pull request '合并到main 创建8.1最后一波修改,后面测试和正式就会分离' (#11) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/11
2026-07-31 11:27:55 +08:00
jacy 1bc3bec977 Merge pull request 'Dev Main by v3.4 测试数据删除' (#10) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/10
2026-07-28 17:19:21 +08:00
jacy 81a6e3674b Merge pull request 'Dev' (#9) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/9
2026-07-17 10:50:17 +08:00
33 changed files with 930 additions and 557 deletions
@@ -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>
);
}
+13 -82
View File
@@ -18,6 +18,7 @@ import type { ColumnsType } from 'antd/es/table';
import type { AdminBenefitGrantRequest } from '@dukang/shared-types'; import type { AdminBenefitGrantRequest } from '@dukang/shared-types';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { COUPON_STATUS_LABELS, fmtTime } from '../lib/constants'; import { COUPON_STATUS_LABELS, fmtTime } from '../lib/constants';
import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
type Row = { type Row = {
@@ -242,7 +243,8 @@ export default function BenefitCouponsPage() {
<Drawer <Drawer
title="权益券详情" title="权益券详情"
width={720} width={980}
styles={{ body: { paddingBottom: 24 } }}
open={drawerOpen} open={drawerOpen}
onClose={() => setDrawerOpen(false)} onClose={() => setDrawerOpen(false)}
extra={ extra={
@@ -310,14 +312,15 @@ export default function BenefitCouponsPage() {
size="small" size="small"
rowKey="id" rowKey="id"
pagination={false} pagination={false}
tableLayout="fixed"
locale={{ emptyText: '暂无关联核销单' }} locale={{ emptyText: '暂无关联核销单' }}
dataSource={detail.redeemRecords ?? []} dataSource={detail.redeemRecords ?? []}
columns={[ columns={[
{ title: '核销号', dataIndex: 'redeemNo', width: 160, ellipsis: true }, { title: '核销号', dataIndex: 'redeemNo', ellipsis: true },
{ {
title: '角色', title: '角色',
dataIndex: 'role', dataIndex: 'role',
width: 70, width: 72,
render: (v: string | undefined) => render: (v: string | undefined) =>
v === 'SECONDARY' ? <Tag color="orange"></Tag> : <Tag color="blue"></Tag>, v === 'SECONDARY' ? <Tag color="orange"></Tag> : <Tag color="blue"></Tag>,
}, },
@@ -331,31 +334,31 @@ export default function BenefitCouponsPage() {
{ {
title: '本券分摊', title: '本券分摊',
dataIndex: 'couponAmount', dataIndex: 'couponAmount',
width: 95, width: 96,
render: (v: number | undefined, row: CouponRedeemRecord) => render: (v: number | undefined, row: CouponRedeemRecord) =>
`¥${Number(v ?? row.amount).toFixed(2)}`, `¥${Number(v ?? row.amount).toFixed(2)}`,
}, },
{ {
title: '核销总额', title: '核销总额',
dataIndex: 'amount', dataIndex: 'amount',
width: 90, width: 96,
render: (v: number) => `¥${Number(v).toFixed(2)}`, render: (v: number) => `¥${Number(v).toFixed(2)}`,
}, },
{ {
title: '结算额', title: '结算额',
dataIndex: 'settleAmount', dataIndex: 'settleAmount',
width: 90, width: 96,
render: (v: number) => `¥${Number(v).toFixed(2)}`, render: (v: number) => `¥${Number(v).toFixed(2)}`,
}, },
{ {
title: '时间', title: '时间',
dataIndex: 'createdAt', dataIndex: 'createdAt',
width: 150, width: 148,
render: (v: string) => fmtTime(v), render: (v: string) => fmtTime(v),
}, },
{ {
title: '操作', title: '操作',
width: 70, width: 64,
render: (_: unknown, row: CouponRedeemRecord) => ( render: (_: unknown, row: CouponRedeemRecord) => (
<Button type="link" size="small" onClick={() => void openRedeemDetail(row.id)}> <Button type="link" size="small" onClick={() => void openRedeemDetail(row.id)}>
@@ -374,7 +377,7 @@ export default function BenefitCouponsPage() {
<Drawer <Drawer
title="核销单详情" title="核销单详情"
width={520} width={640}
open={redeemDrawerOpen} open={redeemDrawerOpen}
onClose={() => { onClose={() => {
setRedeemDrawerOpen(false); setRedeemDrawerOpen(false);
@@ -385,79 +388,7 @@ export default function BenefitCouponsPage() {
{redeemDetailLoading ? ( {redeemDetailLoading ? (
<Typography.Text type="secondary"></Typography.Text> <Typography.Text type="secondary"></Typography.Text>
) : redeemDetail ? ( ) : redeemDetail ? (
<Descriptions column={1} bordered size="small"> <RedeemRecordDetailDescriptions detail={redeemDetail} />
<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>
) : null} ) : null}
</Drawer> </Drawer>
</div> </div>
+29 -38
View File
@@ -1,7 +1,8 @@
import { useState } from 'react'; 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 type { ColumnsType } from 'antd/es/table';
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types'; import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
@@ -39,6 +40,17 @@ export default function RedeemRecordsPage() {
); );
const [detail, setDetail] = useState<Record<string, unknown> | null>(null); const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false); 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> = [ const columns: ColumnsType<Row> = [
{ title: '核销号', dataIndex: 'redeemNo', width: 180 }, { title: '核销号', dataIndex: 'redeemNo', width: 180 },
@@ -77,14 +89,7 @@ export default function RedeemRecordsPage() {
title: '操作', title: '操作',
width: 80, width: 80,
render: (_, row) => ( render: (_, row) => (
<Button <Button type="link" size="small" onClick={() => void openDetail(row.id)}>
type="link"
size="small"
onClick={async () => {
setDetail(await request(`/admin/redeem-records/${row.id}`));
setDrawerOpen(true);
}}
>
</Button> </Button>
), ),
@@ -142,35 +147,21 @@ export default function RedeemRecordsPage() {
}, },
}} }}
/> />
<Drawer title="核销详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}> <Drawer
{detail && ( title="核销详情"
<Descriptions column={1} bordered size="small"> width={640}
<Descriptions.Item label="核销号">{String(detail.redeemNo)}</Descriptions.Item> open={drawerOpen}
<Descriptions.Item label="方式"> onClose={() => {
{ setDrawerOpen(false);
REDEEM_CHANNEL_LABELS[ setDetail(null);
(detail.channel === 'PHONE' ? 'PHONE' : 'SCAN') as RedeemChannel }}
] destroyOnClose
} >
</Descriptions.Item> {detailLoading ? (
<Descriptions.Item label="核销额">¥{String(detail.amount)}</Descriptions.Item> <Typography.Text type="secondary"></Typography.Text>
<Descriptions.Item label="结算额">¥{String(detail.settleAmount)}</Descriptions.Item> ) : detail ? (
{detail.user && typeof detail.user === 'object' ? ( <RedeemRecordDetailDescriptions detail={detail} />
<> ) : null}
<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> </Drawer>
</div> </div>
); );
+11 -4
View File
@@ -376,7 +376,7 @@ type StoreRow = {
visibilityWhitelistEnabled?: boolean; visibilityWhitelistEnabled?: boolean;
visibilityPhones?: string[]; visibilityPhones?: string[];
cityRef?: { name: string; code: string }; cityRef?: { name: string; code: string };
partner?: { companyName: string }; partner?: { id?: string; companyName?: string | null; name?: string | null; phone?: string | null };
account?: { account?: {
phone: string; phone: string;
name: string; name: string;
@@ -394,13 +394,14 @@ type StoreRow = {
category?: { id: string; name: string; parentId?: string | null } | null; 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 { function partnerOptionLabel(p: PartnerOption): string {
const company = (p.companyName || '').trim(); const company = (p.companyName || '').trim();
const person = (p.name || '').trim(); const person = (p.name || '').trim();
const phone = (p.phone || '').trim();
if (company && person) return `${company}-${person}`; if (company && person) return `${company}-${person}`;
return company || person || p.id; return company || person || phone || p.id;
} }
type CityOption = { type CityOption = {
id: string; 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: '可见', title: '可见',
dataIndex: 'visibilityWhitelistEnabled', dataIndex: 'visibilityWhitelistEnabled',
@@ -56,6 +56,7 @@ const STATUS_COLOR: Record<SupportTicketStatusDto, string> = {
DEVELOPING: 'blue', DEVELOPING: 'blue',
TESTING: 'purple', TESTING: 'purple',
PASSED: 'green', PASSED: 'green',
PUBLISHED: 'cyan',
}; };
const TYPE_OPTIONS = (Object.keys(SUPPORT_TICKET_TYPE_LABELS) as SupportTicketTypeDto[]).map( const TYPE_OPTIONS = (Object.keys(SUPPORT_TICKET_TYPE_LABELS) as SupportTicketTypeDto[]).map(
@@ -482,8 +483,15 @@ export default function SupportTicketsPage() {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
width: 100, width: 100,
render: (s: SupportTicketStatusDto) => ( render: (s: SupportTicketStatusDto, row) => (
<Tag color={STATUS_COLOR[s]}>{SUPPORT_TICKET_STATUS_LABELS[s] ?? s}</Tag> <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 }, { title: '标题', dataIndex: 'title', ellipsis: true },
@@ -666,6 +674,11 @@ export default function SupportTicketsPage() {
{detail.status === 'PASSED' && ( {detail.status === 'PASSED' && (
<Typography.Text type="success"></Typography.Text> <Typography.Text type="success"></Typography.Text>
)} )}
{detail.status === 'PUBLISHED' && (
<Typography.Text type="success">
{detail.releasedVersionNo ? ` · ${detail.releasedVersionNo}` : ''}
</Typography.Text>
)}
</Space> </Space>
<Typography.Text type="secondary" style={{ fontSize: 12 }}> <Typography.Text type="secondary" style={{ fontSize: 12 }}>
{fmtTime(detail.createdAt)} {fmtTime(detail.createdAt)}
@@ -689,6 +702,7 @@ export default function SupportTicketsPage() {
{ key: 'DEVELOPING' as SupportTicketStatusDto, label: '开发中', time: detail.reviewedAt }, { key: 'DEVELOPING' as SupportTicketStatusDto, label: '开发中', time: detail.reviewedAt },
{ key: 'TESTING' as SupportTicketStatusDto, label: '测试中', time: null }, { key: 'TESTING' as SupportTicketStatusDto, label: '测试中', time: null },
{ key: 'PASSED' as SupportTicketStatusDto, label: '已通过', time: detail.completedAt }, { key: 'PASSED' as SupportTicketStatusDto, label: '已通过', time: detail.completedAt },
{ key: 'PUBLISHED' as SupportTicketStatusDto, label: '已发布', time: detail.publishedAt },
] ]
: [{ key: 'REJECTED' as SupportTicketStatusDto, label: '已驳回', time: detail.reviewedAt }]), : [{ key: 'REJECTED' as SupportTicketStatusDto, label: '已驳回', time: detail.reviewedAt }]),
]; ];
+37 -4
View File
@@ -10,11 +10,18 @@ import {
Select, Select,
Space, Space,
Table, Table,
Tag,
Typography, Typography,
message, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; 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 { request } from '../lib/api';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
@@ -31,6 +38,21 @@ type Row = {
createdAt: string; 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() { export default function TicketsPage() {
const [filters, setFilters] = useState<Record<string, string>>({}); const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>( const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
@@ -101,7 +123,14 @@ export default function TicketsPage() {
width: 110, width: 110,
render: (t: TicketTypeDto) => TICKET_TYPE_LABELS[t] ?? t, 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: '关联', width: 140, render: (_, r) => `${r.refType}#${r.refId}` },
{ title: '备注', dataIndex: 'remark', ellipsis: true }, { title: '备注', dataIndex: 'remark', ellipsis: true },
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
@@ -165,7 +194,7 @@ export default function TicketsPage() {
/> />
</Form.Item> </Form.Item>
<Form.Item name="status" label="状态"> <Form.Item name="status" label="状态">
<Input allowClear placeholder="PENDING" /> <Select allowClear style={{ width: 140 }} placeholder="全部" options={STATUS_OPTIONS} />
</Form.Item> </Form.Item>
<Button type="primary" htmlType="submit"> <Button type="primary" htmlType="submit">
@@ -212,7 +241,11 @@ export default function TicketsPage() {
<Descriptions.Item label="类型"> <Descriptions.Item label="类型">
{TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType} {TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType}
</Descriptions.Item> </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="关联"> <Descriptions.Item label="关联">
{String(detail.refType)} #{String(detail.refId)} {String(detail.refType)} #{String(detail.refId)}
</Descriptions.Item> </Descriptions.Item>
+13
View File
@@ -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> { export async function fetchClientConfig(): Promise<ClientRuntimeConfig> {
return request<ClientRuntimeConfig>('PARTNER_H5', '/common/client-config'); return request<ClientRuntimeConfig>('PARTNER_H5', '/common/client-config');
} }
+25 -5
View File
@@ -12,8 +12,10 @@ import {
} from '../lib/api'; } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { partnerHomePath } from '../lib/partnerAccess'; import { partnerHomePath } from '../lib/partnerAccess';
import type { PartnerPhoneCheckResponse } from '@dukang/shared-types';
import { import {
bindPartnerWechatAfterSmsLogin, bindPartnerWechatAfterSmsLogin,
checkPartnerPhoneForLogin,
fetchClientConfig, fetchClientConfig,
loginPartnerWithWechat, loginPartnerWithWechat,
} from '../lib/wechat-auth'; } from '../lib/wechat-auth';
@@ -69,14 +71,17 @@ function formatPartnerError(e: unknown): string {
if (text.includes('合伙人账号不存在') || text.includes('未找到合伙人账号')) { if (text.includes('合伙人账号不存在') || text.includes('未找到合伙人账号')) {
return '未找到合伙人账号'; return '未找到合伙人账号';
} }
if (text.includes('合伙人账号已停用')) { if (text.includes('合伙人账号已停用') || text.includes('账号已停用')) {
return '合伙人账号已停用'; return '合伙人账号已暂停,无法登录';
} }
return text; return text;
} }
function formatWechatError(e: unknown): string { function formatWechatError(e: unknown): string {
const text = e instanceof Error ? e.message : '微信登录失败'; const text = e instanceof Error ? e.message : '微信登录失败';
if (text.includes('合伙人账号已停用') || text.includes('账号已停用')) {
return '合伙人账号已暂停,无法登录';
}
if (text.includes('首次登录') || text.includes('手机验证码')) { if (text.includes('首次登录') || text.includes('手机验证码')) {
return '该微信尚未绑定合伙人账号,请先使用手机验证码登录,登录后将自动关联微信'; return '该微信尚未绑定合伙人账号,请先使用手机验证码登录,登录后将自动关联微信';
} }
@@ -138,11 +143,21 @@ export default function LoginPage() {
async function sendCode() { async function sendCode() {
if (!ensureAgreed()) return; if (!ensureAgreed()) return;
const trimmedPhone = phone.trim();
if (!trimmedPhone) {
setMsg('请输入手机号');
return;
}
if (!/^1\d{10}$/.test(trimmedPhone)) {
setMsg('请输入正确的手机号');
return;
}
setMsg(''); setMsg('');
try { try {
await checkPartnerPhoneForLogin(trimmedPhone);
await request('PARTNER_H5', '/partner/auth/sms/send', { await request('PARTNER_H5', '/partner/auth/sms/send', {
method: 'POST', method: 'POST',
body: JSON.stringify({ phone, scene: 'PARTNER_LOGIN' }), body: JSON.stringify({ phone: trimmedPhone, scene: 'PARTNER_LOGIN' }),
silent: true, silent: true,
}); });
setMsg('验证码已发送'); setMsg('验证码已发送');
@@ -171,16 +186,21 @@ export default function LoginPage() {
const trimmedPhone = phone.trim(); const trimmedPhone = phone.trim();
const trimmedCode = code.trim(); const trimmedCode = code.trim();
if (!trimmedPhone) { if (!trimmedPhone) {
setMsg('phone should not be empty'); setMsg('请输入手机号');
return;
}
if (!/^1\d{10}$/.test(trimmedPhone)) {
setMsg('请输入正确的手机号');
return; return;
} }
if (!trimmedCode) { if (!trimmedCode) {
setMsg('code should not be empty'); setMsg('请输入验证码');
return; return;
} }
setLoading(true); setLoading(true);
setMsg(''); setMsg('');
try { try {
await checkPartnerPhoneForLogin(trimmedPhone);
const data = await request<PartnerSessionPayload>('PARTNER_H5', '/partner/auth/login/sms', { const data = await request<PartnerSessionPayload>('PARTNER_H5', '/partner/auth/login/sms', {
method: 'POST', method: 'POST',
body: JSON.stringify({ phone: trimmedPhone, code: trimmedCode }), body: JSON.stringify({ phone: trimmedPhone, code: trimmedCode }),
@@ -19,6 +19,7 @@ import {
import { import {
fetchClientConfig, fetchClientConfig,
handleShopWechatCallback, handleShopWechatCallback,
handleShopWechatCallbackOnce,
handleShopWechatLoginResult, handleShopWechatLoginResult,
isShopWechatUnboundError, isShopWechatUnboundError,
SHOP_WX_NEED_PHONE_LOGIN_MSG, SHOP_WX_NEED_PHONE_LOGIN_MSG,
@@ -79,7 +80,7 @@ export function StoreSessionProvider({ children }: { children: ReactNode }) {
try { try {
const config = await fetchClientConfig(); const config = await fetchClientConfig();
if (isWxAuthorizeEnabled(config)) { if (isWxAuthorizeEnabled(config)) {
const result = await handleShopWechatCallback(); const result = await handleShopWechatCallbackOnce();
if (result && !cancelled) { if (result && !cancelled) {
const session = handleShopWechatLoginResult(result); const session = handleShopWechatLoginResult(result);
if (session) applySession(session); if (session) applySession(session);
+33
View File
@@ -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 回跳后延迟再调 scanQRCodeiOS JSSDK 离线校验更慢) */
export function getPostAuthScanDelayMs(): number {
return isIosDevice() ? 1200 : 600;
}
+14
View File
@@ -138,6 +138,20 @@ export async function handleShopWechatCallback(): Promise<WechatLoginResult | nu
return weixinSdk.handleOAuthCallback(); 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 = 已跳转授权页等待回调。 * 返回 session = 已登录;void = 已跳转授权页等待回调。
+293 -285
View File
@@ -1,285 +1,293 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh'; import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useStoreSession } from '../contexts/StoreSessionContext';
import { request } from '../lib/api'; import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { parseRedeemTokenFromScan } from '../lib/redeem-scan';
import { import { isScanPermissionWarmupError } from '@dukang/weixin-sdk';
authorizeShopWechat,
checkNeedsWechatAuth, import { useStoreSession } from '../contexts/StoreSessionContext';
fetchShopAccount,
handleShopWechatCallback, import { request } from '../lib/api';
handleShopWechatLoginResult,
} from '../lib/wechat-auth'; import { parseRedeemTokenFromScan } from '../lib/redeem-scan';
import { isWechatEnv, weixinSdk } from '../lib/weixin';
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk'; import {
import WechatScanAuthModal from '../components/WechatScanAuthModal';
import { useStorePageView } from '../lib/usePageView'; authorizeShopWechat,
import { trackStore } from '../lib/analytics';
checkNeedsWechatAuth,
const PENDING_SCAN_KEY = 'shop_pending_scan';
fetchShopAccount,
function formatMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); } from '../lib/wechat-auth';
}
import { isWechatEnv, weixinSdk } from '../lib/weixin';
function formatScanError(e: unknown): string {
const msg = e instanceof Error ? e.message : '扫码失败,请重试'; import {
if (/invalid signature/i.test(msg)) {
return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名,并刷新页面后重试'; clearPendingScanAfterAuth,
}
if (/offline verifying|权限验证中|接口未就绪/i.test(msg)) { getPostAuthScanDelayMs,
return '微信权限校验尚未完成,请等待 1~2 秒后再次点击扫码(首次绑定微信时较常见)';
} markPendingScanAfterAuth,
return msg;
} peekPendingScanAfterAuth,
export default function HomePage() { } from '../lib/shop-scan-auth';
useStorePageView('store_home_view');
const navigate = useNavigate(); import WechatScanAuthModal from '../components/WechatScanAuthModal';
const { applySession } = useStoreSession();
const [searchParams, setSearchParams] = useSearchParams(); import { useStorePageView } from '../lib/usePageView';
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
const [scanMsg, setScanMsg] = useState(''); import { trackStore } from '../lib/analytics';
const [scanning, setScanning] = useState(false);
const [authModalOpen, setAuthModalOpen] = useState(false);
const [authLoading, setAuthLoading] = useState(false);
const [authError, setAuthError] = useState(''); function formatMoney(n: number) {
const loadDashboard = useCallback(() => { return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
return request<Record<string, unknown>>('SHOP_H5', '/shop/dashboard')
.then((d) => { }
setDash(d);
})
.catch(() => {});
}, []); function formatScanError(e: unknown, opts?: { afterAuth?: boolean }): string {
useEffect(() => { const msg = e instanceof Error ? e.message : '扫码失败,请重试';
void loadDashboard();
}, [loadDashboard]); if (/invalid signature/i.test(msg)) {
useEffect(() => { return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名,并刷新页面后重试';
function onResume() {
setScanning(false); }
void loadDashboard();
} if (isScanPermissionWarmupError(msg)) {
function onVisibility() {
if (document.visibilityState === 'visible') onResume(); if (opts?.afterAuth) {
}
document.addEventListener('visibilitychange', onVisibility); return '微信授权已完成,扫码权限仍在准备中,请再点一次「扫码核销」';
window.addEventListener('pageshow', onResume);
window.addEventListener('focus', onResume); }
return () => {
document.removeEventListener('visibilitychange', onVisibility); return '微信权限校验尚未完成,请等待 1~2 秒后再次点击扫码(首次绑定微信时较常见)';
window.removeEventListener('pageshow', onResume);
window.removeEventListener('focus', onResume); }
};
}, [loadDashboard]); return msg;
async function runScan(opts?: { postAuthWarmup?: boolean }) { }
trackStore('store_redeem_scan_start');
if (!isWechatEnv()) {
setScanMsg('请在微信内打开门店端进行扫码核销');
return; export default function HomePage() {
}
setScanning(true); useStorePageView('store_home_view');
setScanMsg('');
try { const navigate = useNavigate();
// 授权回跳后强制重签,避免沿用带 code 的旧签名态
if (opts?.postAuthWarmup) { const { ready, authenticated } = useStoreSession();
weixinSdk.reset();
} const [searchParams] = useSearchParams();
await weixinSdk.init();
const raw = await weixinSdk.scanQrCode( const [dash, setDash] = useState<Record<string, unknown> | null>(null);
opts?.postAuthWarmup ? { postAuthWarmup: true } : undefined,
); const [scanMsg, setScanMsg] = useState('');
if (!raw) {
void loadDashboard(); const [scanning, setScanning] = useState(false);
return;
} const [authModalOpen, setAuthModalOpen] = useState(false);
const token = parseRedeemTokenFromScan(raw);
if (!token) { const [authLoading, setAuthLoading] = useState(false);
setScanMsg('无法识别核销码,请扫描用户出示的核销二维码');
return; const [authError, setAuthError] = useState('');
}
navigate(`/redeem?token=${encodeURIComponent(token)}`); const pendingScanStartedRef = useRef(false);
} catch (e) {
setScanMsg(formatScanError(e));
} finally {
setScanning(false); const loadDashboard = useCallback(() => {
}
} return request<Record<string, unknown>>('SHOP_H5', '/shop/dashboard')
useEffect(() => { .then((d) => {
if (!isWechatEnv() || !searchParams.get('code')) return;
void handleShopWechatCallback() setDash(d);
.then((result) => {
if (!result) return; })
const session = handleShopWechatLoginResult(result);
if (session) { .catch(() => {});
applySession(session);
} }, []);
setAuthModalOpen(false);
setAuthError('');
// 先清 OAuth 参数再扫,保证 JSSDK 签名 URL 与当前页一致
stripOAuthParamsFromLocation(); useEffect(() => {
setSearchParams({}, { replace: true });
const shouldScan = sessionStorage.getItem(PENDING_SCAN_KEY) === '1'; void loadDashboard();
sessionStorage.removeItem(PENDING_SCAN_KEY);
if (shouldScan) { }, [loadDashboard]);
// OAuth 回跳后微信权限离线校验未完成;稍候再扫,失败可再点一次
window.setTimeout(() => void runScan({ postAuthWarmup: true }), 400);
}
}) useEffect(() => {
.catch((e) => {
setAuthError(e instanceof Error ? e.message : '微信授权失败'); function onResume() {
});
}, [searchParams, applySession, setSearchParams]); setScanning(false);
async function handleScan() { void loadDashboard();
setScanMsg('');
if (!isWechatEnv()) { }
setScanMsg('请在微信内打开门店端进行扫码核销');
return; function onVisibility() {
}
try { if (document.visibilityState === 'visible') onResume();
const profile = await fetchShopAccount();
if (await checkNeedsWechatAuth(profile)) { }
setAuthModalOpen(true);
return; document.addEventListener('visibilitychange', onVisibility);
}
await runScan(); window.addEventListener('pageshow', onResume);
} catch (e) {
setScanMsg(e instanceof Error ? e.message : '无法发起扫码'); window.addEventListener('focus', onResume);
}
} return () => {
async function startWechatAuth() { document.removeEventListener('visibilitychange', onVisibility);
setAuthLoading(true);
setAuthError(''); window.removeEventListener('pageshow', onResume);
try {
sessionStorage.setItem(PENDING_SCAN_KEY, '1'); window.removeEventListener('focus', onResume);
await authorizeShopWechat();
} catch (e) { };
sessionStorage.removeItem(PENDING_SCAN_KEY);
setAuthError(e instanceof Error ? e.message : '微信授权失败'); }, [loadDashboard]);
setAuthLoading(false);
}
}
const runScan = useCallback(
const store = dash?.store as Record<string, unknown> | undefined;
const recent = (dash?.recentRecords as Array<Record<string, unknown>>) || []; async (opts?: { postAuthWarmup?: boolean }) => {
const status = String(store?.status || '');
const open = status === 'OPEN'; trackStore('store_redeem_scan_start');
const hoursParts: string[] = [];
if (store?.openTime && store?.closeTime) hoursParts.push(`${store.openTime} - ${store.closeTime}`); if (!isWechatEnv()) {
if (store?.openTime2 && store?.closeTime2) hoursParts.push(`${store.openTime2} - ${store.closeTime2}`);
const hoursText = hoursParts.length ? hoursParts.join('') : '10:00 - 22:00'; setScanMsg('请在微信内打开门店端进行扫码核销');
const statusText =
status === 'CLOSED' ? '永久关闭' : open ? '当前正在营业中' : '当前临时闭店'; return;
return ( }
<PullToRefresh onRefresh={loadDashboard} className="shop-home-page">
<header className="shop-home-header"> setScanning(true);
<h1 className="app-page-title"></h1>
</header> if (!opts?.postAuthWarmup) {
<div className="shop-home-content"> setScanMsg('');
<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> try {
</div>
<div className="shop-home-stats"> if (opts?.postAuthWarmup) {
<div className="shop-home-stat">
<p className="shop-home-stat-label"></p> weixinSdk.reset();
<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> await weixinSdk.init();
</div>
<div className="shop-home-stat"> const raw = await weixinSdk.scanQrCode(
<p className="shop-home-stat-label"></p>
<p className="shop-home-stat-value"> opts?.postAuthWarmup ? { postAuthWarmup: true } : undefined,
<span style={{ fontSize: 18 }}>¥</span>
{formatMoney(Number(dash?.todayAmount || 0))} );
</p>
</div> if (!raw) {
</div>
</section> void loadDashboard();
<section className="shop-home-scan"> return;
<button
type="button" }
className="shop-home-scan-btn"
disabled={scanning} const token = parseRedeemTokenFromScan(raw);
onClick={() => void handleScan()}
> if (!token) {
<span className="material-symbols-outlined">qr_code_scanner</span>
</button> setScanMsg('无法识别核销码,请扫描用户出示的核销二维码');
<p className="shop-home-scan-label">{scanning ? '正在打开相机…' : '扫码核销'}</p>
{scanMsg && <p className="shop-home-scan-msg" role="alert">{scanMsg}</p>} return;
<Link to="/redeem/phone" className="shop-home-phone-link">
<span className="material-symbols-outlined">smartphone</span> }
</Link> navigate(`/redeem?token=${encodeURIComponent(token)}`);
</section>
} catch (e) {
<section className="shop-home-status">
<div className="shop-home-status-left"> setScanMsg(formatScanError(e, { afterAuth: opts?.postAuthWarmup }));
<div className={`shop-home-status-icon${open ? '' : ' closed'}`}>
<span className="material-symbols-outlined shop-fill-icon">schedule</span> } finally {
</div>
<div> setScanning(false);
<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')}> [loadDashboard, navigate],
<input type="checkbox" checked={open && status !== 'CLOSED'} readOnly tabIndex={-1} />
<span className="shop-home-switch-track" /> );
</label>
</section>
<section> // OAuth 由 StoreSessionContext 单例处理;此处仅在授权完成后续扫
<div className="shop-home-records-head">
<h3 className="shop-home-records-title"></h3> useEffect(() => {
<Link to="/records" className="shop-home-records-link">
if (!ready || !authenticated || !isWechatEnv()) return;
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>chevron_right</span>
</Link> if (searchParams.get('code')) return;
</div>
<div className="shop-home-record-list"> if (!peekPendingScanAfterAuth() || pendingScanStartedRef.current) return;
{recent.length === 0 && (
<p className="shop-home-status-sub" style={{ textAlign: 'center', padding: '16px 0' }}></p>
)}
{recent.map((r) => ( pendingScanStartedRef.current = true;
<div key={String(r.id)} className="shop-home-record-item">
<div> clearPendingScanAfterAuth();
<p className="shop-home-record-time"></p>
<p className="shop-home-record-value"> setAuthModalOpen(false);
{new Date(String(r.createdAt)).toLocaleString('zh-CN')}
</p> setAuthLoading(false);
</div>
<p className="shop-home-record-amount">¥{formatMoney(Number(r.amount))}</p> setAuthError('');
</div>
))} setScanMsg('微信授权成功,正在准备扫码…');
</div>
</section>
</div>
const timer = window.setTimeout(() => {
<WechatScanAuthModal
open={authModalOpen} void runScan({ postAuthWarmup: true });
loading={authLoading}
error={authError} }, getPostAuthScanDelayMs());
onAuthorize={() => void startWechatAuth()}
onCancel={() => {
setAuthModalOpen(false);
setAuthError(''); return () => window.clearTimeout(timer);
sessionStorage.removeItem(PENDING_SCAN_KEY);
}} }, [ready, authenticated, searchParams, runScan]);
/>
</PullToRefresh>
);
} async function handleScan() {
setScanMsg('');
if (!isWechatEnv()) {
setScanMsg('请在微信内打开门店端进行扫码核销');
return;
+2 -2
View File
@@ -18,7 +18,7 @@ import {
consumeShopWechatLoginHint, consumeShopWechatLoginHint,
fetchClientConfig, fetchClientConfig,
formatShopWechatError, formatShopWechatError,
handleShopWechatCallback, handleShopWechatCallbackOnce,
handleShopWechatLoginResult, handleShopWechatLoginResult,
loginShopWithWechat, loginShopWithWechat,
} from '../lib/wechat-auth'; } from '../lib/wechat-auth';
@@ -88,7 +88,7 @@ export default function LoginPage() {
useEffect(() => { useEffect(() => {
if (!isWechatEnv() || !wxAuthorize || !params.get('code')) return; if (!isWechatEnv() || !wxAuthorize || !params.get('code')) return;
void handleShopWechatCallback() void handleShopWechatCallbackOnce()
.then((result) => { .then((result) => {
if (!result) return; if (!result) return;
const session = handleShopWechatLoginResult(result); const session = handleShopWechatLoginResult(result);
+2 -2
View File
@@ -9,7 +9,7 @@ import {
authorizeShopWechat, authorizeShopWechat,
fetchClientConfig, fetchClientConfig,
fetchShopAccount, fetchShopAccount,
handleShopWechatCallback, handleShopWechatCallbackOnce,
handleShopWechatLoginResult, handleShopWechatLoginResult,
} from '../lib/wechat-auth'; } from '../lib/wechat-auth';
import { isWechatEnv } from '../lib/weixin'; import { isWechatEnv } from '../lib/weixin';
@@ -45,7 +45,7 @@ export default function MinePage() {
useEffect(() => { useEffect(() => {
if (!isWechatEnv() || !wxAuthorize || !searchParams.get('code')) return; if (!isWechatEnv() || !wxAuthorize || !searchParams.get('code')) return;
void handleShopWechatCallback() void handleShopWechatCallbackOnce()
.then((result) => { .then((result) => {
if (!result) return; if (!result) return;
const session = handleShopWechatLoginResult(result); const session = handleShopWechatLoginResult(result);
@@ -20,7 +20,7 @@ export default function ProductCarousel({
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const prefix = const prefix =
variant === 'store' ? 'store-detail-carousel' : variant === 'home' ? 'home-carousel' : 'detail-carousel'; variant === 'store' ? 'store-detail-carousel' : variant === 'home' ? 'home-carousel' : 'detail-carousel';
const imageMode = variant === 'store' ? 'aspectFit' : 'aspectFill'; const imageMode = 'aspectFill';
function previewAt(index: number) { function previewAt(index: number) {
const urls = slides.filter(Boolean); const urls = slides.filter(Boolean);
+67 -6
View File
@@ -4,6 +4,8 @@ import { fetchClientConfig } from './pay-wechat';
/** 与 package.json version 同步,供服务端 minClientVersion 比对 */ /** 与 package.json version 同步,供服务端 minClientVersion 比对 */
export const APP_VERSION = '3.4.13'; export const APP_VERSION = '3.4.13';
export const APP_VERSION_LABEL = `v${APP_VERSION}`;
function parseSemver(v: string): number[] { function parseSemver(v: string): number[] {
return v.split('.').map((n) => parseInt(n, 10) || 0); return v.split('.').map((n) => parseInt(n, 10) || 0);
} }
@@ -20,17 +22,76 @@ export function compareSemver(a: string, b: string): number {
return 0; 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() { export async function checkClientVersionGate() {
try { try {
const config = await fetchClientConfig(); const config = await fetchClientConfig();
const min = config.minClientVersion?.trim(); const min = config.minClientVersion?.trim();
if (min && compareSemver(APP_VERSION, min) < 0) { if (min && compareSemver(APP_VERSION, min) < 0) {
await Taro.showModal({ await enforceMinClientVersion(min);
title: '版本过低',
content: '当前小程序版本过低,请更新至最新版本后继续使用。',
showCancel: false,
confirmText: '我知道了',
});
} }
} catch { } catch {
/* 配置拉取失败不阻塞启动 */ /* 配置拉取失败不阻塞启动 */
+2 -1
View File
@@ -23,6 +23,7 @@ import {
} from '../../lib/mini-wechat-profile'; } from '../../lib/mini-wechat-profile';
import { isLoggedIn, logout, request, toast, type UserProfile } from '../../lib/api'; import { isLoggedIn, logout, request, toast, type UserProfile } from '../../lib/api';
import { isWechatEnv } from '../../lib/weixin'; import { isWechatEnv } from '../../lib/weixin';
import { APP_VERSION_LABEL } from '../../lib/client-version';
import { maskPhone } from '../../lib/phone'; import { maskPhone } from '../../lib/phone';
import { import {
DEFAULT_SHARE_DESC, DEFAULT_SHARE_DESC,
@@ -499,7 +500,7 @@ export default function MinePage() {
</View> </View>
<View className="mine-footer"> <View className="mine-footer">
<Text className="mine-version"></Text> <Text className="mine-version"> {APP_VERSION_LABEL}</Text>
<Text className="mine-logout" onClick={() => logout()}> <Text className="mine-logout" onClick={() => logout()}>
退 退
</Text> </Text>
+45 -34
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; 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, { import Taro, {
useDidShow, useDidShow,
useLoad, useLoad,
@@ -156,10 +156,14 @@ export default function StoreDetailPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(''); const [loadError, setLoadError] = useState('');
const [headerSolid, setHeaderSolid] = useState(false); const [headerSolid, setHeaderSolid] = useState(false);
const [expandedPackages, setExpandedPackages] = useState<Record<number, boolean>>({}); const [activePackageIndex, setActivePackageIndex] = useState(0);
const storeRef = useRef<Store | null>(null); const storeRef = useRef<Store | null>(null);
storeRef.current = store; storeRef.current = store;
useEffect(() => {
setActivePackageIndex(0);
}, [store?.id]);
usePageScroll(({ scrollTop }) => { usePageScroll(({ scrollTop }) => {
setHeaderSolid(scrollTop > 100); setHeaderSolid(scrollTop > 100);
}); });
@@ -321,6 +325,8 @@ export default function StoreDetailPage() {
const envPhotos = envPhotoUrls(store); const envPhotos = envPhotoUrls(store);
const heroImages = uniqueUrls([store.coverUrl, ...(store.carouselUrls || [])]); const heroImages = uniqueUrls([store.coverUrl, ...(store.carouselUrls || [])]);
const packages = store.packages ?? [];
const activePackage = packages[activePackageIndex] ?? packages[0];
const intro = store.intro?.trim() || ''; const intro = store.intro?.trim() || '';
const benefitRuleRaw = store.benefitUsageRule?.trim() || ''; const benefitRuleRaw = store.benefitUsageRule?.trim() || '';
@@ -402,44 +408,49 @@ export default function StoreDetailPage() {
</View> </View>
) : null} ) : null}
{store.packages && store.packages.length > 0 ? ( {packages.length > 0 && activePackage ? (
<View className="store-detail-section"> <View className="store-detail-section store-detail-section--packages">
<Text className="store-detail-section-title"></Text> <Text className="store-detail-section-title"></Text>
{store.packages.map((pkg, index) => { {packages.length > 1 ? (
const expanded = !!expandedPackages[index]; <ScrollView className="store-detail-package-tabs" scrollX showScrollbar={false} enhanced>
const bodyText = `${formatRedeemAmountYuan(pkg.price)} 元 · ${pkg.dishes}`; <View className="store-detail-package-tabs-inner">
const longBody = bodyText.length > 48 || (pkg.otherNotes?.length ?? 0) > 40; {packages.map((pkg, index) => (
return ( <View
<View key={`${pkg.name}-${index}`}
key={`${pkg.name}-${index}`} className={`store-detail-package-tab${
className={`store-detail-package-card${expanded || !longBody ? '' : ' store-detail-package-card--collapsed'}`} index === activePackageIndex ? ' store-detail-package-tab--active' : ''
> }`}
{pkg.imageUrl ? ( onClick={() => setActivePackageIndex(index)}
<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] }))
}
> >
{expanded ? '收起' : '展开'} <Text className="store-detail-package-tab-text">{pkg.name}</Text>
</Text> </View>
) : null} ))}
</View> </View>
<Text className="store-detail-package-body">{bodyText}</Text> </ScrollView>
{pkg.usableTime ? ( ) : null}
<Text className="store-detail-package-meta">使{pkg.usableTime}</Text> <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} ) : null}
{pkg.otherNotes ? ( <Text className="store-detail-package-body">
<Text className="store-detail-package-meta">{pkg.otherNotes}</Text> {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} ) : null}
</View> </View>
); </View>
})}
</View> </View>
) : null} ) : null}
+6 -9
View File
@@ -343,11 +343,10 @@ export default function StoresPage() {
} }
function formatHours(store: Store) { function formatHours(store: Store) {
// 列表只展示第一段营业时间,避免挤占一行 const parts: string[] = [];
if (store.openTime && store.closeTime) { if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`);
return `营业时间: ${store.openTime}-${store.closeTime}`; if (store.openTime2 && store.closeTime2) parts.push(`${store.openTime2}-${store.closeTime2}`);
} return parts.length ? `营业时间: ${parts.join('')}` : '营业时间: 10:00-22:00';
return '营业时间: 10:00-22:00';
} }
function formatStatus(store: Store) { function formatStatus(store: Store) {
@@ -447,12 +446,10 @@ export default function StoresPage() {
{formatDistanceMeters(s.distanceMeters)} {formatDistanceMeters(s.distanceMeters)}
</Text> </Text>
</View> </View>
{/* 第2行:状态 + 营业时间(仅第一段) */} {/* 第2行:状态 + 营业时间(含第二段) */}
<View className="store-card-row store-card-row--meta"> <View className="store-card-row store-card-row--meta">
<Text className="store-card-status">{formatStatus(s)}</Text> <Text className="store-card-status">{formatStatus(s)}</Text>
<Text className="store-card-hours" numberOfLines={1}> <Text className="store-card-hours">{formatHours(s)}</Text>
{formatHours(s)}
</Text>
</View> </View>
{/* 第3行:地址 + 去核销 */} {/* 第3行:地址 + 去核销 */}
<View className="store-card-row store-card-row--foot"> <View className="store-card-row store-card-row--foot">
+84 -40
View File
@@ -10,15 +10,14 @@
.store-detail-carousel-wrap { .store-detail-carousel-wrap {
position: relative; position: relative;
width: 100%; width: 100%;
min-height: 200px; aspect-ratio: 4 / 3;
max-height: 360px;
overflow: hidden; overflow: hidden;
background: var(--color-surface-container); background: var(--color-surface-container);
} }
.store-detail-carousel { .store-detail-carousel {
width: 100%; width: 100%;
height: 280px; height: 100%;
} }
.store-detail-carousel-item, .store-detail-carousel-item,
@@ -28,6 +27,11 @@
height: 100%; height: 100%;
} }
.store-detail-carousel-image {
object-fit: cover;
display: block;
}
.store-detail-carousel-placeholder { .store-detail-carousel-placeholder {
background: var(--color-surface-container); background: var(--color-surface-container);
} }
@@ -238,62 +242,102 @@
margin-right: 0; margin-right: 0;
} }
.store-detail-package-card { .store-detail-section--packages {
padding: 12px 0; overflow: hidden;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
} }
.store-detail-package-card--collapsed .store-detail-package-body, .store-detail-package-tabs {
.store-detail-package-card--collapsed .store-detail-package-meta { 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; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; 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 { .store-detail-package-name {
display: block; display: block;
font-size: 16px; font-size: 15px;
font-weight: 700; font-weight: 700;
color: var(--color-text-primary, #1a1a1a); color: var(--color-text-primary, #1a1a1a);
margin-bottom: 6px; margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.store-detail-package-body { .store-detail-package-body {
display: block; font-size: 13px;
font-size: 14px;
color: var(--color-text-secondary, #666); color: var(--color-text-secondary, #666);
line-height: 1.5; line-height: 1.45;
} }
.store-detail-package-meta { .store-detail-package-meta {
display: block; font-size: 12px;
font-size: 13px;
color: var(--color-text-tertiary, #999); color: var(--color-text-tertiary, #999);
margin-top: 4px; margin-top: 4px;
} }
+5 -6
View File
@@ -218,15 +218,17 @@
white-space: nowrap; white-space: nowrap;
} }
/* 第2行:营业状态 + 营业时间(单行 */ /* 第2行:营业状态 + 营业时间(可含两段 */
.store-card-row--meta { .store-card-row--meta {
gap: 6px; gap: 6px;
height: 20px; min-height: 20px;
align-items: flex-start;
} }
.store-card-status { .store-card-status {
flex-shrink: 0; flex-shrink: 0;
padding: 0 6px; padding: 0 6px;
margin-top: 1px;
border-radius: 4px; border-radius: 4px;
background: rgba(45, 106, 79, 0.12); background: rgba(45, 106, 79, 0.12);
color: #2d6a4f; color: #2d6a4f;
@@ -241,11 +243,8 @@
min-width: 0; min-width: 0;
font-size: 11px; font-size: 11px;
font-weight: 400; font-weight: 400;
line-height: 20px; line-height: 16px;
color: #999; color: #999;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
/* 第3行:地址(单行截断)+ 右对齐去核销 */ /* 第3行:地址(单行截断)+ 右对齐去核销 */
@@ -29,6 +29,15 @@ describe('validateSupportTicketStatusTransition', () => {
).toBe(false); ).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', () => { it('requires super admin and reason for REJECTED', () => {
expect( expect(
validateSupportTicketStatusTransition('DEVELOPING', 'REJECTED', { validateSupportTicketStatusTransition('DEVELOPING', 'REJECTED', {
+4 -2
View File
@@ -3,7 +3,8 @@ export type SupportTicketStatus =
| 'REJECTED' | 'REJECTED'
| 'DEVELOPING' | 'DEVELOPING'
| 'TESTING' | 'TESTING'
| 'PASSED'; | 'PASSED'
| 'PUBLISHED';
export type SupportTicketStatusTransitionContext = { export type SupportTicketStatusTransitionContext = {
linkedTaskCount: number; linkedTaskCount: number;
@@ -16,7 +17,8 @@ const FORWARD_TRANSITIONS: Record<SupportTicketStatus, SupportTicketStatus[]> =
DEVELOPING: ['TESTING', 'REJECTED'], DEVELOPING: ['TESTING', 'REJECTED'],
TESTING: ['PASSED', 'REJECTED'], TESTING: ['PASSED', 'REJECTED'],
REJECTED: [], REJECTED: [],
PASSED: [], PASSED: ['PUBLISHED'],
PUBLISHED: [],
}; };
/** 技术支持工单批量/人工改状态校验(禁止回退) */ /** 技术支持工单批量/人工改状态校验(禁止回退) */
+6 -1
View File
@@ -15,7 +15,8 @@ export type SupportTicketStatusDto =
| 'REJECTED' | 'REJECTED'
| 'DEVELOPING' | 'DEVELOPING'
| 'TESTING' | 'TESTING'
| 'PASSED'; | 'PASSED'
| 'PUBLISHED';
export const SUPPORT_TICKET_STATUSES = [ export const SUPPORT_TICKET_STATUSES = [
'PENDING_REVIEW', 'PENDING_REVIEW',
@@ -23,6 +24,7 @@ export const SUPPORT_TICKET_STATUSES = [
'DEVELOPING', 'DEVELOPING',
'TESTING', 'TESTING',
'PASSED', 'PASSED',
'PUBLISHED',
] as const; ] as const;
export const SUPPORT_TICKET_STATUS_LABELS: Record<SupportTicketStatusDto, string> = { export const SUPPORT_TICKET_STATUS_LABELS: Record<SupportTicketStatusDto, string> = {
@@ -31,6 +33,7 @@ export const SUPPORT_TICKET_STATUS_LABELS: Record<SupportTicketStatusDto, string
DEVELOPING: '开发', DEVELOPING: '开发',
TESTING: '测试', TESTING: '测试',
PASSED: '通过', PASSED: '通过',
PUBLISHED: '已发布',
}; };
/** 技术支持工单优先级 */ /** 技术支持工单优先级 */
@@ -64,6 +67,8 @@ export interface SupportTicketDto {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
completedAt?: string | null; completedAt?: string | null;
releasedVersionNo?: string | null;
publishedAt?: string | null;
} }
export interface CreateSupportTicketRequest { export interface CreateSupportTicketRequest {
+24
View File
@@ -27,6 +27,30 @@ export const TICKET_TYPE_LABELS: Record<TicketTypeDto, string> = {
PACKAGE_DISPUTE: '套餐异议', 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 { export interface TicketDto {
id: string; id: string;
ticketNo: string; ticketNo: string;
+2 -2
View File
@@ -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 { getRouterBasename, toAppPath, isOnAppPath } from './app-path';
export { export {
initWechatJssdk, initWechatJssdk,
@@ -10,7 +10,7 @@ export {
resetJssdkConfig, resetJssdkConfig,
stripOAuthParamsFromLocation, stripOAuthParamsFromLocation,
} from './jssdk'; } from './jssdk';
export { formatScanFailMessage } from './scan'; export { formatScanFailMessage, isScanPermissionWarmupError } from './scan';
export { export {
getWechatLocation, getWechatLocation,
getWechatLocationDetailed, getWechatLocationDetailed,
+8 -9
View File
@@ -38,7 +38,7 @@ export function formatScanFailMessage(errMsg: string): string {
return msg; 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( return /offline verifying|permission value is offline|权限验证中|接口未就绪|invalid signature|config:fail|signature/i.test(
msg, msg,
); );
@@ -134,15 +134,13 @@ export async function scanQrCode(
throw new Error('当前微信版本不支持扫码,请升级微信后重试'); throw new Error('当前微信版本不支持扫码,请升级微信后重试');
} }
// wx.ready ≠ 权限离线校验完成;授权回跳后更明显 const ios = isIosDevice() && !isWechatDevTools();
const warmupMs = options.postAuthWarmup
? 1200 // wx.ready ≠ 权限离线校验完成;OAuth 回跳后 iOS 更明显
: isIosDevice() && !isWechatDevTools() const warmupMs = options.postAuthWarmup ? (ios ? 1600 : 1000) : ios ? 800 : 300;
? 800
: 300;
await delay(warmupMs); await delay(warmupMs);
const maxAttempts = options.postAuthWarmup ? 3 : 2; const maxAttempts = options.postAuthWarmup ? (ios ? 4 : 3) : 2;
let lastError: Error | null = null; let lastError: Error | null = null;
for (let attempt = 0; attempt < maxAttempts; attempt++) { for (let attempt = 0; attempt < maxAttempts; attempt++) {
if (attempt > 0) { if (attempt > 0) {
@@ -151,7 +149,8 @@ export async function scanQrCode(
await initWechatJssdk({ ...jssdkOptions, url: getJssdkSignUrl() }); await initWechatJssdk({ ...jssdkOptions, url: getJssdkSignUrl() });
await checkJsApi('scanQRCode'); await checkJsApi('scanQRCode');
} }
await delay(700 + attempt * 500); const retryDelay = options.postAuthWarmup && ios ? 900 + attempt * 700 : 700 + attempt * 500;
await delay(retryDelay);
} }
try { try {
return await invokeScanQrCode(); return await invokeScanQrCode();
+4
View File
@@ -107,6 +107,7 @@ enum SupportTicketStatus {
DEVELOPING DEVELOPING
TESTING TESTING
PASSED PASSED
PUBLISHED
} }
enum SupportTicketPriority { enum SupportTicketPriority {
@@ -671,6 +672,8 @@ model CommonSupportTicket {
reviewedAt DateTime? @map("reviewed_at") @db.DateTime(3) reviewedAt DateTime? @map("reviewed_at") @db.DateTime(3)
remark String? @db.VarChar(512) remark String? @db.VarChar(512)
attachmentUrls Json? @map("attachment_urls") 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) createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
completedAt DateTime? @map("completed_at") @db.DateTime(3) completedAt DateTime? @map("completed_at") @db.DateTime(3)
@@ -678,6 +681,7 @@ model CommonSupportTicket {
@@index([status, createdAt]) @@index([status, createdAt])
@@index([ticketType, status]) @@index([ticketType, status])
@@index([creatorId]) @@index([creatorId])
@@index([releasedVersionNo])
@@map("common_support_ticket") @@map("common_support_ticket")
devPlanTasks DevPlanTask[] devPlanTasks DevPlanTask[]
@@ -671,6 +671,10 @@ export class DevPlanService {
} }
if (status === 'RELEASED') {
await this.cascadeVersionReleased(row.id, row.versionNo);
}
return this.getVersion(row.id); return this.getVersion(row.id);
} }
@@ -739,6 +743,14 @@ export class DevPlanService {
await this.prisma.devPlanVersion.update({ where: { id }, data }); 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); if (dto.taskIds != null) await this.replaceVersionTasks(id, dto.taskIds);
return this.getVersion(id); 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[]) { private async addVersionTasks(versionId: bigint, taskIds: string[]) {
if (!taskIds.length) return; if (!taskIds.length) return;
@@ -74,7 +74,7 @@ export class AdminStoresService {
take: pageSize, take: pageSize,
include: { include: {
cityRef: { select: { id: true, name: true, code: true } }, 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 } }, category: { select: { id: true, name: true, parentId: true } },
bindings: { bindings: {
where: { storeAccount: { isPrimary: 1 } }, where: { storeAccount: { isPrimary: 1 } },
+1 -1
View File
@@ -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) |
--- ---
+1 -1
View File
@@ -339,7 +339,7 @@ C2~C7、C14 见 §1.3。
| 技术支持优先级 | ✅ | Prisma + shared-types + HQ UI | | 技术支持优先级 | ✅ | Prisma + shared-types + HQ UI |
| 合伙人微信暂停禁登 | ✅ | loginPartnerWechat | | 合伙人微信暂停禁登 | ✅ | loginPartnerWechat |
| H5 登录前端校验 | ✅ | partner/shop LoginPage | | H5 登录前端校验 | ✅ | partner/shop LoginPage |
| mini-user 门店体验 | ✅ | 电话脱敏/埋点、门头 aspectFit、套餐折叠 | | mini-user 门店体验 | ✅ | 电话脱敏/埋点、**列表两段营业时间**、门头 aspectFill 铺满、**套餐页签切换** |
| mini-user 商品/提货 | ✅ | 去分享、首图 preview、提货确认弹框 | | mini-user 商品/提货 | ✅ | 去分享、首图 preview、提货确认弹框 |
| mini-user 版本/物流 | ✅ | 摘要 + 签收照 + 拨号 + 时间线 + ETA;回调签收→**COMPLETED** | | mini-user 版本/物流 | ✅ | 摘要 + 签收照 + 拨号 + 时间线 + ETA;回调签收→**COMPLETED** |
| OSS 大图压缩 | ✅ | shared-ui compressImage;四端 upload + mini 头像 | | OSS 大图压缩 | ✅ | shared-ui compressImage;四端 upload + mini 头像 |
@@ -7,10 +7,10 @@
| 模块 | 内容 | | 模块 | 内容 |
|------|------| |------|------|
| admin-web | 推广码 attributionCount + **指标事件日志/ECharts 趋势**核销记录用户信息;技术支持工单优先级**大图上传前压缩** | | admin-web | 推广码 attributionCount + **指标事件日志/ECharts 趋势****权益券详情加宽**;**核销记录详情增强(对齐权益券内核销详情)**;技术支持工单优先级;**版本发布联动任务/工单已发布****大图上传前压缩** |
| mini-user | 门店电话脱敏+拨打埋点;门头/套餐展示;商品去分享+首图 preview;提货确认弹框;版本更新提示;**物流增强(签收照/拨号/时间线/ETA)**;**头像超 10MB 压缩** | | mini-user | 门店电话脱敏+拨打埋点;**门店列表展示两段营业时间**;**门店详情门头 aspectFill 铺满无留白**;**门店套餐页签切换**;商品去分享+首图 preview;提货确认弹框;**我的页版本号 v3.4.x****低于 minClientVersion 强制更新/点我知道了退出**;**物流增强(签收照/拨号/时间线/ETA)**;**头像超 10MB 压缩** |
| h5-partner / h5-shop / h5-user | 登录 phone/code 前端校验;**OSS 图片超 10MB 自动 canvas 压缩后上传** | | h5-partner / h5-shop / h5-user | 登录 phone/code 前端校验;**门店端 iOS 首次扫码 OAuth 单例 + JSSDK 预热重试****OSS 图片超 10MB 自动 canvas 压缩后上传** |
| 后端 | 工单 priorityclient-config minClientVersion;合伙人微信暂停禁登;**Courier 适配器****log_promo_event 推广码指标日志** | | 后端 | 工单 priority**工单 PUBLISHED + releasedVersionNo****版本 RELEASED 联动任务/工单**client-config minClientVersion;合伙人微信暂停禁登;**Courier 适配器****log_promo_event 推广码指标日志** |
## 2. ST 映射 ## 2. ST 映射
@@ -19,21 +19,21 @@
| ST1785925037781309 | 总部端-推广码数据跟踪优化 | ✅ attributionCount + **事件日志 + ECharts 趋势** | | ST1785925037781309 | 总部端-推广码数据跟踪优化 | ✅ attributionCount + **事件日志 + ECharts 趋势** |
| ST1785924286682833 | 用户端-门店电话加密+拨打埋点 | ✅ maskPhone + store_phone_call | | ST1785924286682833 | 用户端-门店电话加密+拨打埋点 | ✅ maskPhone + store_phone_call |
| ST1785921693982470 | 技术支持-工单优先级 | ✅ priority 枚举 + HQ UI | | ST1785921693982470 | 技术支持-工单优先级 | ✅ priority 枚举 + HQ UI |
| ST1785921585900725 | 门店端扫一扫授权异常 | ✅ 已有(v3.4.12 前) | | ST1785921585900725 | 门店端扫一扫授权异常 | ✅ **OAuth 单例回调 + iOS JSSDK 预热/重试 + 授权后续扫** |
| ST1785907536648201 | 版本不对提示更新 | ✅ UpdateManager + minClientVersion | | ST1785907536648201 | 版本不对提示更新 | ✅ 我的页 v3.4.x + UpdateManager + **低于 min 强制更新/退出** |
| ST1785906800359657 | 合伙人暂停后禁登 | ✅ loginPartnerWechat status | | ST1785906800359657 | 合伙人暂停后禁登 | ✅ **发码前 phone/check + 微信/SMS 登录均拒 DISABLED** |
| ST1785906592340255 | PARTNER_H5 login 校验 | ✅ 前端空字段拦截 | | ST1785906592340255 | PARTNER_H5 login 校验 | ✅ 前端空字段拦截 |
| ST1785906390321653 | 现场提货提交确认弹框 | ✅ showModal | | ST1785906390321653 | 现场提货提交确认弹框 | ✅ showModal |
| ST1785905773501871 | 核销记录用户信息 | ✅ 列表+详情 | | ST1785905773501871 | 核销记录用户信息 | ✅ 列表+详情;**详情含门店/合伙人/券分摊/结算/评价** |
| ST1785904849234806 | 工单中心 | ✅ 已有 | | ST1785904849234806 | 工单中心 | ✅ 已有 |
| ST1785904076632841 | 门店列表开城合伙人 | ✅ 已有 | | ST1785904076632841 | 门店列表开城合伙人 | ✅ **列表展示 companyName/name/phone(同 partnerOptionLabel** |
| ST1785902173093977 | 去掉商品详情分享按钮 | ✅ 移除 ShareNavButton | | ST1785902173093977 | 去掉商品详情分享按钮 | ✅ 移除 ShareNavButton |
| ST1785902141731113 | 商品首图大图 | ✅ previewable | | ST1785902141731113 | 商品首图大图 | ✅ previewable |
| ST1785901870913349 | 门店列表营业时间 | ✅ 已有 | | ST1785901870913349 | 门店列表营业时间 | ✅ **列表展示两段营业时间** |
| ST1785901838948572 | SHOP 未绑定门店 | ✅ 已有 | | ST1785901838948572 | SHOP 未绑定门店 | ✅ 已有 |
| ST1785901775231811 | 门店套餐遮挡 | ✅ 折叠/行数限制 | | ST1785901775231811 | 门店套餐遮挡 | ✅ **页签切换(可横滑)+ 紧凑内容区** |
| ST1785901711627824 | SHOP login 校验 | ✅ 前端空字段拦截 | | ST1785901711627824 | SHOP login 校验 | ✅ 前端空字段拦截 |
| ST1785901314893145 | 门头照裁剪 | ✅ aspectFit + preview | | ST1785901314893145 | 门头照裁剪 | ✅ aspectFill 铺满无留白 + preview |
| ST1785939375449985 | 订单物流追踪页 | ✅ 签收照 + 拨号 + 时间线 + ETA + 路由回调 | | ST1785939375449985 | 订单物流追踪页 | ✅ 签收照 + 拨号 + 时间线 + ETA + 路由回调 |
| — | 上传图片超 10MB 先压缩 | ✅ `@dukang/shared-ui/compressImage` + 各端 upload | | — | 上传图片超 10MB 先压缩 | ✅ `@dukang/shared-ui/compressImage` + 各端 upload |
@@ -41,8 +41,10 @@
| 方法 | 路径 / 配置 | 说明 | | 方法 | 路径 / 配置 | 说明 |
|------|-------------|------| |------|-------------|------|
| 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.priority` | `LOW \| NORMAL \| HIGH \| URGENT`,默认 NORMAL |
| — | `CommonSupportTicket.status` | 新增 `PUBLISHED`(已发布);`releasedVersionNo` + `publishedAt` |
| — | `DevPlanVersion``RELEASED` | 关联任务 → `RELEASED`;关联工单 → `PUBLISHED` 并写入版本号 |
| — | `loginPartnerWechat` | 非 ACTIVE 账号抛出「合伙人账号已停用」 | | — | `loginPartnerWechat` | 非 ACTIVE 账号抛出「合伙人账号已停用」 |
| GET | `/trade/orders/:id/track` | 聚合:`nodes`(旧→新)、`signPhotoUrls``estimatedArrival`;经 `CourierService` 适配小飞侠 cmd 100102/100108/100301 | | GET | `/trade/orders/:id/track` | 聚合:`nodes`(旧→新)、`signPhotoUrls``estimatedArrival`;经 `CourierService` 适配小飞侠 cmd 100102/100108/100301 |
| POST | `/callbacks/courier/xfx/track` | 小飞侠路由变化回调(适配器入口) | | POST | `/callbacks/courier/xfx/track` | 小飞侠路由变化回调(适配器入口) |
@@ -66,6 +68,15 @@
**HQ UI**:推广码详情页 → 数据趋势 Card(DatePicker + 按日/按时 + ECharts 四曲线 + 事件 Table **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 ### 4. mini-user 物流(ST1785939375449985
| 页面 | 行为 | | 页面 | 行为 |
@@ -126,21 +137,26 @@ POST https://api-test.dukanghaoke.com/api/v1/callbacks/courier/xfx/track
## 5. 数据表 ## 5. 数据表
- `common_support_ticket.priority` ENUM,默认 `NORMAL` - `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` - `log_promo_event`:推广码指标事件(`promo_code_id`, `event_type`, `user_id`, `order_id`, `session_id`, `client_ip`, 地点, `created_at`
## 6. HQ 开发计划 ## 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 ## 7. 验收 ACC
- [ ] 推广码详情展示 attributionCount**四指标事件日志 + ECharts 按日/按时趋势 + 高峰标注** - [ ] 推广码详情展示 attributionCount**四指标事件日志 + ECharts 按日/按时趋势 + 高峰标注**
- [ ] 推广码 SCAN/ATTRIBUTION/REGISTER/ORDER 事件含 time + IP;幂等(重复 touch 不计 SCAN - [ ] 推广码 SCAN/ATTRIBUTION/REGISTER/ORDER 事件含 time + IP;幂等(重复 touch 不计 SCAN
- [ ] 核销记录含 userNo/nickname/phone - [ ] 核销记录含 userNo/nickname/phone;**详情抽屉展示门店/地址/合伙人/权益券号/关联订单/券分摊/结算单/评价**
- [ ] 技术支持可创建/筛选/编辑优先级 - [ ] **权益券详情抽屉加宽(980px),内嵌核销列表无横向滚动条**
- [ ] 合伙人/门店登录空字段前端提示;暂停合伙人微信登录被拒 - [ ] 技术支持可创建/筛选/编辑优先级;**版本 RELEASED 后关联工单为「已发布」并带版本号**
- [ ] mini-user:电话脱敏、拨打埋点、提货确认、无分享按钮、首图 preview、门头/套餐正常 - [ ] **门店列表「开城合伙人」列展示 companyName / name / phone(与下拉选项一致)**
- [ ] 合伙人/门店登录空字段前端提示;**暂停合伙人发码前即拦截,短信/微信登录均禁止**
- [ ] mini-user:我的页左下角显示 `杜康好客 v3.4.x`;低于 `minClientVersion` 时强制更新,无新包则点「我知道了」退出小程序
- [ ] mini-user:电话脱敏、拨打埋点、提货确认、无分享按钮、首图 preview、**门店列表两段营业时间**、**门店详情门头铺满无留白**、**门店套餐页签切换**
- [ ] mini-user:配送中物流摘要 + 签收照预览 + 电话拨号 + 时间线旧→新全点亮 + 预估送达 - [ ] mini-user:配送中物流摘要 + 签收照预览 + 电话拨号 + 时间线旧→新全点亮 + 预估送达
- [ ] 小飞侠路由回调 `POST /callbacks/courier/xfx/track`status=5 或 statusName 已签收 → 订单 **COMPLETED**;重复回调幂等 - [ ] 小飞侠路由回调 `POST /callbacks/courier/xfx/track`status=5 或 statusName 已签收 → 订单 **COMPLETED**;重复回调幂等
- [ ] 各端上传 >10MB 图片自动压缩后可上传;仍超限有明确报错 - [ ] 各端上传 >10MB 图片自动压缩后可上传;仍超限有明确报错
- [ ] **门店 H5iPhone 微信)**:首次扫码 OAuth 后自动续扫;offline verifying 时提示再点一次扫码
- [ ] `pnpm lint` 无新增错误 - [ ] `pnpm lint` 无新增错误