Compare commits

...

45 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 607a0224cd merge(dev_jacy): promo metric logs and HQ timeline (v3.4.13) 2026-08-06 00:06:59 +08:00
jacy 53e0db6a98 feat(promo): promo metric event logs and HQ timeline charts (v3.4.13)
Add log_promo_event for scan/attribution/register/order with IP; admin metrics APIs and ECharts on promo detail page.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 00:06:52 +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 1f7ecffe4c merge(dev_jacy): courier signed callback completes order (v3.4.13) 2026-08-05 23:46:44 +08:00
jacy 8df0a2bea7 feat(trade): complete order on courier signed callback (v3.4.13)
When XFX route callback reports status=5 or statusName containing 签收, map to COMPLETED instead of PENDING_RECEIVE; document in v3.4.13 docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 23:46:37 +08:00
jacy 672a995c59 fix(mini-user): split order-logistics utils for weapp bundle stability
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 23:41:31 +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 9cb88dc181 merge(dev_jacy): v3.4.13 logistics enhancements 2026-08-05 23:34:17 +08:00
jacy 345d5db9c9 feat(mini-user): logistics sign photos, dial, ETA and courier callbacks (v3.4.13)
Extend Courier adapter for XFX sign photos and route callbacks; mini-user logistics UI with timeline, phone dial, and estimated arrival.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 23:34:09 +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 2610288df0 merge(dev_jacy): logistics detail page polish (v3.4.13) 2026-08-05 23:14:26 +08:00
jacy 62652cd072 fix(mini-user): polish logistics detail page layout and timeline order (v3.4.13)
Use custom navigation bar, remove delivery status section, and show newest track nodes first.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 23:14:18 +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 bdbd135318 merge(dev_jacy): mini-user logistics preview and detail timeline (v3.4.13) 2026-08-05 23:03:49 +08:00
jacy 8ca893efab feat(mini-user): show shipping logistics preview and detail timeline (v3.4.13)
Order detail displays latest XFX route under 配送中 with link to full timeline page; docs updated.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 23:03:41 +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 50ab0f1748 merge(dev_jacy): v3.4.13 upload compression and winery T+3 fix 2026-08-05 22:48:44 +08:00
jacy fa6fb80d19 feat(upload): compress images over 10MB before OSS upload (v3.4.13)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 22:48:34 +08:00
jacy bd1930f6b1 fix(settlement): align winery bill T+3 window to order completedAt
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 22:36:51 +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 256526c5ec merge(dev_jacy): v3.4.13 experience optimizations 2026-08-05 22:34:40 +08:00
jacy 1c4b986924 feat(release): v3.4.13 experience optimizations across admin, mini-user, and H5 login
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 22:34:29 +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 442500b1a1 merge(dev_jacy): support ticket batch select and task bind 2026-08-05 08:08:24 +08:00
jacy 490c26a7e1 feat(admin): unrestricted support ticket batch select and task ticket bind
Allow selecting support tickets of any status for batch actions; add optional support ticket binding when creating a dev plan task.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 08:08:17 +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 f80a0f2c27 Merge pull request '修改门店列表组件' (#12) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/12
2026-08-05 00:57:14 +08:00
jacy 528669f662 修改门店列表组件
CI / verify (pull_request) Has been cancelled
2026-08-05 00:55:58 +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 16fa07ceaf feat(mini-user): redesign store list with square thumbnail cards
Switch store list to horizontal card layout with square cover image, status badge, hours, address and solid redeem CTA per design mockup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 00:11:25 +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 02d89e6385 feat(ops): kb doc edit, validation auto-tickets, support batch ops
Add knowledge document GET/PUT and admin edit UI; auto-create support tickets on client 400 validation errors across user/shop/partner apps; batch create tasks and publish from support tickets; restore mini-user store env single-column layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 00:08:40 +08:00
104 changed files with 4166 additions and 680 deletions
+1
View File
@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@ant-design/icons": "^5.5.1", "@ant-design/icons": "^5.5.1",
"@dukang/shared-types": "workspace:*", "@dukang/shared-types": "workspace:*",
"@dukang/shared-ui": "workspace:*",
"antd": "^5.22.0", "antd": "^5.22.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"echarts": "^6.1.0", "echarts": "^6.1.0",
@@ -15,8 +15,6 @@ type OssUploadProps = {
placeholder?: string; placeholder?: string;
}; };
const DEFAULT_MAX_MB = 10;
export default function OssUpload({ export default function OssUpload({
value, value,
onChange, onChange,
@@ -24,7 +22,6 @@ export default function OssUpload({
bizType, bizType,
mediaType = 'IMAGE', mediaType = 'IMAGE',
accept, accept,
maxSizeMb = DEFAULT_MAX_MB,
placeholder = '上传后自动填入,或手动粘贴 URL', placeholder = '上传后自动填入,或手动粘贴 URL',
}: OssUploadProps) { }: OssUploadProps) {
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
@@ -34,12 +31,6 @@ export default function OssUpload({
const customRequest: UploadProps['customRequest'] = async ({ file, onSuccess, onError }) => { const customRequest: UploadProps['customRequest'] = async ({ file, onSuccess, onError }) => {
const raw = file as File; const raw = file as File;
if (raw.size > maxSizeMb * 1024 * 1024) {
const err = new Error(`文件不能超过 ${maxSizeMb}MB`);
message.error(err.message);
onError?.(err);
return;
}
setUploading(true); setUploading(true);
try { try {
const result = await uploadFileToOss(raw, { bizType, mediaType }); const result = await uploadFileToOss(raw, { bizType, mediaType });
@@ -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>
);
}
+16 -1
View File
@@ -1,4 +1,9 @@
import { apiBase, CLIENT_APP, getToken, clearAuth } from './api'; import { apiBase, CLIENT_APP, getToken, clearAuth } from './api';
import {
compressImageFileIfNeeded,
DEFAULT_OSS_MAX_UPLOAD_BYTES,
formatOssMaxSizeMb,
} from '@dukang/shared-ui/compressImage';
export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE'; export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE';
@@ -9,14 +14,24 @@ export type UploadFileResult = {
mock: boolean; mock: boolean;
}; };
async function prepareUploadFile(file: File, mediaType: OssMediaType): Promise<File> {
if (mediaType !== 'IMAGE') return file;
const compressed = await compressImageFileIfNeeded(file);
if (compressed.size > DEFAULT_OSS_MAX_UPLOAD_BYTES) {
throw new Error(`图片压缩后仍超过 ${formatOssMaxSizeMb()}MB,请换一张较小的图片`);
}
return compressed;
}
/** 经 API 服务端转存 OSS,避免浏览器直传跨域 */ /** 经 API 服务端转存 OSS,避免浏览器直传跨域 */
export async function uploadFileToOss( export async function uploadFileToOss(
file: File, file: File,
options: { bizType: string; mediaType?: OssMediaType }, options: { bizType: string; mediaType?: OssMediaType },
): Promise<UploadFileResult> { ): Promise<UploadFileResult> {
const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE'); const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE');
const prepared = await prepareUploadFile(file, mediaType);
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', prepared);
formData.append('bizType', options.bizType); formData.append('bizType', options.bizType);
formData.append('mediaType', mediaType); formData.append('mediaType', mediaType);
+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>
+30 -3
View File
@@ -16,10 +16,12 @@ import type { ColumnsType } from 'antd/es/table';
import { import {
DEV_PLAN_TASK_STATUS_LABELS, DEV_PLAN_TASK_STATUS_LABELS,
DEV_PLAN_TASK_TYPE_LABELS, DEV_PLAN_TASK_TYPE_LABELS,
SUPPORT_TICKET_STATUS_LABELS,
type DevPlanTaskDto, type DevPlanTaskDto,
type DevPlanTaskStatusDto, type DevPlanTaskStatusDto,
type DevPlanTaskTypeDto, type DevPlanTaskTypeDto,
type DevPlanVersionDto, type DevPlanVersionDto,
type SupportTicketDto,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
@@ -50,6 +52,7 @@ export default function DevPlanTasksPage() {
const [batchEditOpen, setBatchEditOpen] = useState(false); const [batchEditOpen, setBatchEditOpen] = useState(false);
const [batchSaving, setBatchSaving] = useState(false); const [batchSaving, setBatchSaving] = useState(false);
const [versions, setVersions] = useState<DevPlanVersionDto[]>([]); const [versions, setVersions] = useState<DevPlanVersionDto[]>([]);
const [supportTickets, setSupportTickets] = useState<SupportTicketDto[]>([]);
const [editing, setEditing] = useState<DevPlanTaskDto | null>(null); const [editing, setEditing] = useState<DevPlanTaskDto | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [dispatching, setDispatching] = useState(false); const [dispatching, setDispatching] = useState(false);
@@ -64,6 +67,13 @@ export default function DevPlanTasksPage() {
.catch(() => setVersions([])); .catch(() => setVersions([]));
}, [batchEditOpen]); }, [batchEditOpen]);
useEffect(() => {
if (!modalOpen || editing) return;
request<{ items: SupportTicketDto[] }>('/admin/support-tickets?pageSize=100')
.then((res) => setSupportTickets(res.items ?? []))
.catch(() => setSupportTickets([]));
}, [modalOpen, editing]);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<DevPlanTaskDto>( const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<DevPlanTaskDto>(
'/admin/dev-plan/tasks', '/admin/dev-plan/tasks',
() => { () => {
@@ -78,7 +88,7 @@ export default function DevPlanTasksPage() {
function openCreate() { function openCreate() {
setEditing(null); setEditing(null);
form.setFieldsValue({ content: '', type: 'BUG', status: 'TODO' }); form.setFieldsValue({ content: '', type: 'BUG', status: 'TODO', supportTicketId: undefined });
setModalOpen(true); setModalOpen(true);
} }
@@ -101,7 +111,11 @@ export default function DevPlanTasksPage() {
} else { } else {
await request('/admin/dev-plan/tasks', { await request('/admin/dev-plan/tasks', {
method: 'POST', method: 'POST',
body: JSON.stringify({ content: values.content, type: values.type }), body: JSON.stringify({
content: values.content,
type: values.type,
supportTicketId: values.supportTicketId || undefined,
}),
}); });
message.success('已创建'); message.success('已创建');
} }
@@ -305,7 +319,20 @@ export default function DevPlanTasksPage() {
<Form.Item name="status" label="状态" rules={[{ required: true }]}> <Form.Item name="status" label="状态" rules={[{ required: true }]}>
<Select options={STATUS_OPTIONS} /> <Select options={STATUS_OPTIONS} />
</Form.Item> </Form.Item>
) : null} ) : (
<Form.Item name="supportTicketId" label="绑定技术支持工单">
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="可选:关联工单"
options={supportTickets.map((t) => ({
value: t.id,
label: `${t.ticketNo} · ${SUPPORT_TICKET_STATUS_LABELS[t.status]} · ${t.title}`,
}))}
/>
</Form.Item>
)}
</Form> </Form>
</Modal> </Modal>
+92 -19
View File
@@ -18,6 +18,7 @@ import type { ColumnsType } from 'antd/es/table';
import { UploadOutlined } from '@ant-design/icons'; import { UploadOutlined } from '@ant-design/icons';
import type { import type {
KnowledgeBaseDto, KnowledgeBaseDto,
KnowledgeDocumentDetailDto,
KnowledgeDocumentDto, KnowledgeDocumentDto,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { request } from '../lib/api'; import { request } from '../lib/api';
@@ -49,6 +50,10 @@ export default function KnowledgeBasesPage() {
const [docsLoading, setDocsLoading] = useState(false); const [docsLoading, setDocsLoading] = useState(false);
const [docModalOpen, setDocModalOpen] = useState(false); const [docModalOpen, setDocModalOpen] = useState(false);
const [docSaving, setDocSaving] = useState(false); const [docSaving, setDocSaving] = useState(false);
const [editingDoc, setEditingDoc] = useState<KnowledgeDocumentDto | null>(null);
const [editDocModalOpen, setEditDocModalOpen] = useState(false);
const [editDocSaving, setEditDocSaving] = useState(false);
const [editDocForm] = Form.useForm<DocForm>();
const { data, loading, page, pageSize, setPage, setPageSize, reload } = const { data, loading, page, pageSize, setPage, setPageSize, reload } =
useAdminList<KnowledgeBaseDto>( useAdminList<KnowledgeBaseDto>(
@@ -195,28 +200,52 @@ export default function KnowledgeBasesPage() {
}, },
{ {
title: '操作', title: '操作',
width: 80, width: 140,
render: (_, row) => render: (_, row) =>
drawerKb?.canEditFull ? ( drawerKb?.canEditFull ? (
<Popconfirm <Space>
title="删除文档?" <Button
onConfirm={async () => { type="link"
try { size="small"
await request(`/admin/knowledge-bases/${drawerKb.id}/documents/${row.id}`, { onClick={async () => {
method: 'DELETE', if (!drawerKb) return;
}); try {
message.success('已删除'); const detail = await request<KnowledgeDocumentDetailDto>(
void loadDocs(drawerKb); `/admin/knowledge-bases/${drawerKb.id}/documents/${row.id}`,
reload(); );
} catch (e) { setEditingDoc(row);
message.error(e instanceof Error ? e.message : String(e)); editDocForm.setFieldsValue({
} title: detail.title,
}} contentText: detail.contentText || '',
> });
<Button type="link" size="small" danger> setEditDocModalOpen(true);
} catch (e) {
message.error(e instanceof Error ? e.message : String(e));
}
}}
>
</Button> </Button>
</Popconfirm> <Popconfirm
title="删除文档?"
onConfirm={async () => {
try {
await request(`/admin/knowledge-bases/${drawerKb.id}/documents/${row.id}`, {
method: 'DELETE',
});
message.success('已删除');
void loadDocs(drawerKb);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : String(e));
}
}}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
) : null, ) : null,
}, },
]; ];
@@ -398,6 +427,50 @@ export default function KnowledgeBasesPage() {
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Modal>
<Modal
title="编辑知识文档"
open={editDocModalOpen}
onCancel={() => {
setEditDocModalOpen(false);
setEditingDoc(null);
}}
confirmLoading={editDocSaving}
onOk={async () => {
if (!drawerKb || !editingDoc) return;
const values = await editDocForm.validateFields();
setEditDocSaving(true);
try {
await request(`/admin/knowledge-bases/${drawerKb.id}/documents/${editingDoc.id}`, {
method: 'PUT',
body: JSON.stringify({
title: values.title,
contentText: values.contentText || null,
}),
});
message.success('已保存');
setEditDocModalOpen(false);
setEditingDoc(null);
void loadDocs(drawerKb);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : String(e));
} finally {
setEditDocSaving(false);
}
}}
destroyOnClose
width={640}
>
<Form form={editDocForm} layout="vertical">
<Form.Item name="title" label="标题" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="contentText" label="正文(可粘贴)">
<Input.TextArea rows={8} placeholder="支持 Markdown / 纯文本" />
</Form.Item>
</Form>
</Modal>
</div> </div>
); );
} }
+49 -28
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';
@@ -13,11 +14,16 @@ type Row = {
settleAmount: number; settleAmount: number;
channel?: RedeemChannel; channel?: RedeemChannel;
createdAt: string; createdAt: string;
user?: { userNo: string; phone: string | null }; user?: { userNo: string; phone: string | null; nickname?: string | null };
store?: { name: string; cityName: string }; store?: { name: string; cityName: string };
coupon?: { couponNo: string }; coupon?: { couponNo: string };
}; };
function maskPhone(phone: string | null | undefined) {
if (!phone || phone.length < 7) return phone ?? '—';
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
}
export default function RedeemRecordsPage() { export default function RedeemRecordsPage() {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({}); const [filters, setFilters] = useState<Record<string, string>>({});
@@ -34,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 },
@@ -50,7 +67,19 @@ export default function RedeemRecordsPage() {
); );
}, },
}, },
{ title: '用户', dataIndex: ['user', 'userNo'], width: 110 }, { title: '用户编号', dataIndex: ['user', 'userNo'], width: 110 },
{
title: '用户昵称',
dataIndex: ['user', 'nickname'],
width: 100,
render: (v: string | null | undefined) => v || '—',
},
{
title: '用户手机',
dataIndex: ['user', 'phone'],
width: 120,
render: (v: string | null | undefined) => maskPhone(v),
},
{ title: '门店', dataIndex: ['store', 'name'] }, { title: '门店', dataIndex: ['store', 'name'] },
{ title: '核销额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` }, { title: '核销额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` },
{ title: '结算额', dataIndex: 'settleAmount', width: 90, render: (v) => `¥${v}` }, { title: '结算额', dataIndex: 'settleAmount', width: 90, render: (v) => `¥${v}` },
@@ -60,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>
), ),
@@ -113,7 +135,7 @@ export default function RedeemRecordsPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1100 }} scroll={{ x: 1300 }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
@@ -125,22 +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 ? (
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item> <RedeemRecordDetailDescriptions detail={detail} />
</Descriptions> ) : null}
)}
</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',
+172 -8
View File
@@ -30,12 +30,15 @@ import {
DEV_PLAN_TASK_TYPE_LABELS, DEV_PLAN_TASK_TYPE_LABELS,
SUPPORT_TICKET_STATUS_LABELS, SUPPORT_TICKET_STATUS_LABELS,
SUPPORT_TICKET_TYPE_LABELS, SUPPORT_TICKET_TYPE_LABELS,
SUPPORT_TICKET_PRIORITY_LABELS,
mapSupportTicketTypeToDevPlanTask, mapSupportTicketTypeToDevPlanTask,
type BatchReviewPreviewItem, type BatchReviewPreviewItem,
type BatchReviewPreviewResponse, type BatchReviewPreviewResponse,
type DevPlanTaskTypeDto, type DevPlanTaskTypeDto,
type DevPlanVersionDto,
type SupportTicketDto, type SupportTicketDto,
type SupportTicketLinkedTaskDto, type SupportTicketLinkedTaskDto,
type SupportTicketPriorityDto,
type SupportTicketStatusDto, type SupportTicketStatusDto,
type SupportTicketTypeDto, type SupportTicketTypeDto,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
@@ -53,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(
@@ -63,6 +67,17 @@ const STATUS_OPTIONS = (Object.keys(SUPPORT_TICKET_STATUS_LABELS) as SupportTick
(value) => ({ value, label: SUPPORT_TICKET_STATUS_LABELS[value] }), (value) => ({ value, label: SUPPORT_TICKET_STATUS_LABELS[value] }),
); );
const PRIORITY_COLOR: Record<SupportTicketPriorityDto, string> = {
LOW: 'default',
NORMAL: 'blue',
HIGH: 'orange',
URGENT: 'red',
};
const PRIORITY_OPTIONS = (Object.keys(SUPPORT_TICKET_PRIORITY_LABELS) as SupportTicketPriorityDto[]).map(
(value) => ({ value, label: SUPPORT_TICKET_PRIORITY_LABELS[value] }),
);
const TASK_TYPE_OPTIONS = (Object.keys(DEV_PLAN_TASK_TYPE_LABELS) as DevPlanTaskTypeDto[]).map( const TASK_TYPE_OPTIONS = (Object.keys(DEV_PLAN_TASK_TYPE_LABELS) as DevPlanTaskTypeDto[]).map(
(v) => ({ value: v, label: DEV_PLAN_TASK_TYPE_LABELS[v] }), (v) => ({ value: v, label: DEV_PLAN_TASK_TYPE_LABELS[v] }),
); );
@@ -79,6 +94,7 @@ export default function SupportTicketsPage() {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (filters.ticketType) qs.set('ticketType', filters.ticketType); if (filters.ticketType) qs.set('ticketType', filters.ticketType);
if (filters.status) qs.set('status', filters.status); if (filters.status) qs.set('status', filters.status);
if (filters.priority) qs.set('priority', filters.priority);
return qs; return qs;
}, },
[filters], [filters],
@@ -96,6 +112,10 @@ export default function SupportTicketsPage() {
const [batchConfirming, setBatchConfirming] = useState(false); const [batchConfirming, setBatchConfirming] = useState(false);
const [batchStatusOpen, setBatchStatusOpen] = useState(false); const [batchStatusOpen, setBatchStatusOpen] = useState(false);
const [batchStatusSaving, setBatchStatusSaving] = useState(false); const [batchStatusSaving, setBatchStatusSaving] = useState(false);
const [batchCreateSaving, setBatchCreateSaving] = useState(false);
const [batchPublishOpen, setBatchPublishOpen] = useState(false);
const [batchPublishSaving, setBatchPublishSaving] = useState(false);
const [versions, setVersions] = useState<DevPlanVersionDto[]>([]);
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [editSaving, setEditSaving] = useState(false); const [editSaving, setEditSaving] = useState(false);
const [reviewDecision, setReviewDecision] = useState<'APPROVE' | 'REJECT'>('APPROVE'); const [reviewDecision, setReviewDecision] = useState<'APPROVE' | 'REJECT'>('APPROVE');
@@ -114,9 +134,21 @@ export default function SupportTicketsPage() {
rejectReason?: string; rejectReason?: string;
note?: string; note?: string;
}>(); }>();
const [batchPublishForm] = Form.useForm<{
versionId: string;
dispatchToWecom?: boolean;
dispatchSupplement?: string;
}>();
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN'; const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
useEffect(() => {
if (!batchPublishOpen) return;
request<{ items: DevPlanVersionDto[] }>('/admin/dev-plan/versions?pageSize=100')
.then((res) => setVersions(res.items ?? []))
.catch(() => setVersions([]));
}, [batchPublishOpen]);
useEffect(() => { useEffect(() => {
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {}); request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []); }, []);
@@ -137,6 +169,7 @@ export default function SupportTicketsPage() {
title: values.title.trim(), title: values.title.trim(),
content: values.content?.trim() || undefined, content: values.content?.trim() || undefined,
remark: values.remark?.trim() || undefined, remark: values.remark?.trim() || undefined,
priority: values.priority,
attachmentUrls: (values.attachmentUrls ?? []).map((u: string) => u?.trim()).filter(Boolean), attachmentUrls: (values.attachmentUrls ?? []).map((u: string) => u?.trim()).filter(Boolean),
}), }),
}); });
@@ -200,6 +233,7 @@ export default function SupportTicketsPage() {
title: detail.title, title: detail.title,
content: detail.content || '', content: detail.content || '',
remark: detail.remark || '', remark: detail.remark || '',
priority: detail.priority ?? 'NORMAL',
attachmentUrls: detail.attachmentUrls?.length ? detail.attachmentUrls : [''], attachmentUrls: detail.attachmentUrls?.length ? detail.attachmentUrls : [''],
}); });
setEditOpen(true); setEditOpen(true);
@@ -216,6 +250,7 @@ export default function SupportTicketsPage() {
title: values.title.trim(), title: values.title.trim(),
content: values.content?.trim() || undefined, content: values.content?.trim() || undefined,
remark: values.remark?.trim() || undefined, remark: values.remark?.trim() || undefined,
priority: values.priority,
attachmentUrls: (values.attachmentUrls ?? []).map((u: string) => u?.trim()).filter(Boolean), attachmentUrls: (values.attachmentUrls ?? []).map((u: string) => u?.trim()).filter(Boolean),
}), }),
}); });
@@ -257,6 +292,67 @@ export default function SupportTicketsPage() {
} }
} }
async function submitBatchCreateTasks() {
if (!selectedRowKeys.length) return;
setBatchCreateSaving(true);
try {
const res = await request<{ successCount: number; failCount: number }>(
'/admin/support-tickets/batch-create-tasks',
{
method: 'POST',
body: JSON.stringify({ ticketIds: selectedRowKeys }),
},
);
message.success(`已创建 ${res.successCount} 条任务,跳过/失败 ${res.failCount}`);
setSelectedRowKeys([]);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '批量创建任务失败');
} finally {
setBatchCreateSaving(false);
}
}
function openBatchPublish() {
batchPublishForm.setFieldsValue({
versionId: undefined,
dispatchToWecom: localStorage.getItem(DISPATCH_WECOM_STORAGE_KEY) !== 'false',
dispatchSupplement: DEFAULT_DISPATCH_SUPPLEMENT,
});
setBatchPublishOpen(true);
}
async function submitBatchPublish() {
const values = await batchPublishForm.validateFields();
localStorage.setItem(DISPATCH_WECOM_STORAGE_KEY, values.dispatchToWecom ? 'true' : 'false');
setBatchPublishSaving(true);
try {
const res = await request<{
successCount: number;
failCount: number;
linkedTaskCount: number;
}>('/admin/support-tickets/batch-publish', {
method: 'POST',
body: JSON.stringify({
ticketIds: selectedRowKeys,
versionId: values.versionId,
dispatchToWecom: !!values.dispatchToWecom,
dispatchSupplement: values.dispatchSupplement?.trim() || undefined,
}),
});
message.success(
`已关联 ${res.linkedTaskCount} 条任务到版本,工单成功 ${res.successCount} 条,失败 ${res.failCount}`,
);
setBatchPublishOpen(false);
setSelectedRowKeys([]);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '批量发布失败');
} finally {
setBatchPublishSaving(false);
}
}
async function submitCreateTasks() { async function submitCreateTasks() {
const values = await tasksForm.validateFields(); const values = await tasksForm.validateFields();
const note = reviewForm.getFieldValue('note') as string | undefined; const note = reviewForm.getFieldValue('note') as string | undefined;
@@ -373,12 +469,29 @@ export default function SupportTicketsPage() {
width: 90, width: 90,
render: (t: SupportTicketTypeDto) => SUPPORT_TICKET_TYPE_LABELS[t] ?? t, render: (t: SupportTicketTypeDto) => SUPPORT_TICKET_TYPE_LABELS[t] ?? t,
}, },
{
title: '优先级',
dataIndex: 'priority',
width: 90,
render: (p: SupportTicketPriorityDto) => (
<Tag color={PRIORITY_COLOR[p] ?? 'default'}>
{SUPPORT_TICKET_PRIORITY_LABELS[p] ?? p ?? '普通'}
</Tag>
),
},
{ {
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 },
@@ -440,6 +553,16 @@ export default function SupportTicketsPage() {
<Space> <Space>
{isSuperAdmin ? ( {isSuperAdmin ? (
<> <>
<Button
disabled={!selectedRowKeys.length}
loading={batchCreateSaving}
onClick={() => void submitBatchCreateTasks()}
>
</Button>
<Button disabled={!selectedRowKeys.length} onClick={openBatchPublish}>
</Button>
<Button disabled={!selectedRowKeys.length} loading={acting} onClick={() => void startBatchPreview()}> <Button disabled={!selectedRowKeys.length} loading={acting} onClick={() => void startBatchPreview()}>
</Button> </Button>
@@ -474,6 +597,9 @@ export default function SupportTicketsPage() {
<Form.Item name="status" label="状态"> <Form.Item name="status" label="状态">
<Select allowClear style={{ width: 120 }} options={STATUS_OPTIONS} /> <Select allowClear style={{ width: 120 }} options={STATUS_OPTIONS} />
</Form.Item> </Form.Item>
<Form.Item name="priority" label="优先级">
<Select allowClear style={{ width: 120 }} options={PRIORITY_OPTIONS} />
</Form.Item>
<Button type="primary" htmlType="submit"> <Button type="primary" htmlType="submit">
</Button> </Button>
@@ -489,11 +615,7 @@ export default function SupportTicketsPage() {
isSuperAdmin isSuperAdmin
? { ? {
selectedRowKeys, selectedRowKeys,
onChange: (keys, rows) => { onChange: (keys) => setSelectedRowKeys(keys as string[]),
const pending = rows.filter((r) => r.status === 'PENDING_REVIEW').map((r) => String(r.id));
setSelectedRowKeys(pending.length === rows.length ? (keys as string[]) : pending);
},
getCheckboxProps: (row) => ({ disabled: row.status !== 'PENDING_REVIEW' }),
} }
: undefined : undefined
} }
@@ -519,6 +641,9 @@ export default function SupportTicketsPage() {
{detail.ticketNo} {detail.ticketNo}
</Typography.Text> </Typography.Text>
<Tag>{SUPPORT_TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType}</Tag> <Tag>{SUPPORT_TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType}</Tag>
<Tag color={PRIORITY_COLOR[detail.priority ?? 'NORMAL']}>
{SUPPORT_TICKET_PRIORITY_LABELS[detail.priority ?? 'NORMAL']}
</Tag>
</> </>
)} )}
</Space> </Space>
@@ -549,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)}
@@ -572,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 }]),
]; ];
@@ -777,10 +908,13 @@ export default function SupportTicketsPage() {
</Drawer> </Drawer>
<Modal title="创建技术支持工单" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={() => void submitCreate()} confirmLoading={creating} destroyOnClose okText="提交"> <Modal title="创建技术支持工单" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={() => void submitCreate()} confirmLoading={creating} destroyOnClose okText="提交">
<Form form={createForm} layout="vertical" initialValues={{ ticketType: 'BUG', attachmentUrls: [''] }}> <Form form={createForm} layout="vertical" initialValues={{ ticketType: 'BUG', priority: 'NORMAL', attachmentUrls: [''] }}>
<Form.Item name="ticketType" label="类型" rules={[{ required: true }]}> <Form.Item name="ticketType" label="类型" rules={[{ required: true }]}>
<Select options={TYPE_OPTIONS} /> <Select options={TYPE_OPTIONS} />
</Form.Item> </Form.Item>
<Form.Item name="priority" label="优先级" rules={[{ required: true }]}>
<Select options={PRIORITY_OPTIONS} />
</Form.Item>
<Form.Item name="title" label="标题" rules={[{ required: true }]}> <Form.Item name="title" label="标题" rules={[{ required: true }]}>
<Input maxLength={128} showCount /> <Input maxLength={128} showCount />
</Form.Item> </Form.Item>
@@ -884,6 +1018,9 @@ export default function SupportTicketsPage() {
<Form.Item name="ticketType" label="类型" rules={[{ required: true }]}> <Form.Item name="ticketType" label="类型" rules={[{ required: true }]}>
<Select options={TYPE_OPTIONS} /> <Select options={TYPE_OPTIONS} />
</Form.Item> </Form.Item>
<Form.Item name="priority" label="优先级" rules={[{ required: true }]}>
<Select options={PRIORITY_OPTIONS} />
</Form.Item>
<Form.Item name="title" label="标题" rules={[{ required: true }]}> <Form.Item name="title" label="标题" rules={[{ required: true }]}>
<Input maxLength={128} showCount /> <Input maxLength={128} showCount />
</Form.Item> </Form.Item>
@@ -945,6 +1082,33 @@ export default function SupportTicketsPage() {
</Form> </Form>
</Modal> </Modal>
<Modal
title="一键发布"
open={batchPublishOpen}
onCancel={() => setBatchPublishOpen(false)}
onOk={() => void submitBatchPublish()}
confirmLoading={batchPublishSaving}
destroyOnClose
>
<Typography.Paragraph type="secondary">
{selectedRowKeys.length}
</Typography.Paragraph>
<Form form={batchPublishForm} layout="vertical">
<Form.Item name="versionId" label="开发版本" rules={[{ required: true, message: '请选择开发版本' }]}>
<Select
placeholder="选择版本"
options={versions.map((v) => ({ value: v.id, label: v.versionNo }))}
/>
</Form.Item>
<Form.Item name="dispatchToWecom" valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
<Form.Item name="dispatchSupplement" label="派发补充说明">
<Input.TextArea rows={2} placeholder={DEFAULT_DISPATCH_SUPPLEMENT} />
</Form.Item>
</Form>
</Modal>
<Modal <Modal
title="批量 AI 审核确认" title="批量 AI 审核确认"
open={batchPreviewOpen} open={batchPreviewOpen}
+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>
+1 -1
View File
@@ -258,7 +258,7 @@ export default function WineryBillsPage() {
</Typography.Title> </Typography.Title>
<Typography.Text type="secondary"> <Typography.Text type="secondary">
8:00 × {ratePct}%绿 T+3 8:00 3 / × {ratePct}%绿
</Typography.Text> </Typography.Text>
</Space> </Space>
{canEditWineryBank ? ( {canEditWineryBank ? (
@@ -24,6 +24,7 @@ import {
import { request } from '../../lib/api'; import { request } from '../../lib/api';
import { fmtTime } from '../../lib/constants'; import { fmtTime } from '../../lib/constants';
import type { PromoCodeDetailContext } from './PromoCodeDetailLayout'; import type { PromoCodeDetailContext } from './PromoCodeDetailLayout';
import PromoCodeMetricsPanel from './PromoCodeMetricsPanel';
const descLabelStyle: CSSProperties = { const descLabelStyle: CSSProperties = {
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
@@ -212,6 +213,14 @@ export default function PromoCodeDetailPage() {
<Statistic title="扫码进入次数" value={stats?.scanCount ?? detail.scanCount} /> <Statistic title="扫码进入次数" value={stats?.scanCount ?? detail.scanCount} />
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="归因用户数"
value={stats?.attributionCount ?? 0}
/>
</Card>
</Col>
<Col xs={12} sm={6}> <Col xs={12} sm={6}>
<Card size="small"> <Card size="small">
<Statistic <Statistic
@@ -235,6 +244,8 @@ export default function PromoCodeDetailPage() {
</Col> </Col>
</Row> </Row>
<PromoCodeMetricsPanel promoId={detail.id} />
<Modal <Modal
title="编辑推广码" title="编辑推广码"
open={editOpen} 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>
);
}
@@ -19,8 +19,6 @@ type OssUploadFieldProps = {
onWechatReadyChange?: (ready: boolean) => void; onWechatReadyChange?: (ready: boolean) => void;
}; };
const DEFAULT_MAX_MB = 10;
function isCancelError(msg: string): boolean { function isCancelError(msg: string): boolean {
return /cancel|取消/i.test(msg); return /cancel|取消/i.test(msg);
} }
@@ -79,10 +77,6 @@ export default function OssUploadField({
showUploadError('图片读取失败,请重新选择'); showUploadError('图片读取失败,请重新选择');
return; return;
} }
if (file.size > DEFAULT_MAX_MB * 1024 * 1024) {
showUploadError(`文件不能超过 ${DEFAULT_MAX_MB}MB`);
return;
}
const result = await uploadFileToOss(file, { bizType, mediaType }); const result = await uploadFileToOss(file, { bizType, mediaType });
onChange?.(result.url); onChange?.(result.url);
} }
+7
View File
@@ -1,4 +1,5 @@
import type { PartnerMe, PartnerStaffRole } from '@dukang/shared-types'; import type { PartnerMe, PartnerStaffRole } from '@dukang/shared-types';
import { reportApiError } from '@dukang/client-logging';
import { isOnAppPath, toAppPath } from '@dukang/weixin-sdk'; import { isOnAppPath, toAppPath } from '@dukang/weixin-sdk';
import { showPartnerToast } from './toast'; import { showPartnerToast } from './toast';
@@ -159,6 +160,12 @@ async function rawRequest<T>(
if (json.code !== 0) { if (json.code !== 0) {
const err = new Error(json.message || '请求失败') as Error & { status?: number }; const err = new Error(json.message || '请求失败') as Error & { status?: number };
err.status = json.code === 401 ? 401 : json.code; err.status = json.code === 401 ? 401 : json.code;
if (json.code === 400) {
reportApiError(
{ apiBase, clientApp: CLIENT_APP, getToken: () => localStorage.getItem(ACCESS_TOKEN) },
{ message: json.message || '请求失败', status: 400, url: path, category: 'validation_error' },
);
}
throw err; throw err;
} }
return json.data as T; return json.data as T;
+16 -1
View File
@@ -1,4 +1,9 @@
import { apiBase, request } from './api'; import { apiBase, request } from './api';
import {
compressImageFileIfNeeded,
DEFAULT_OSS_MAX_UPLOAD_BYTES,
formatOssMaxSizeMb,
} from '@dukang/shared-ui/compressImage';
const UPLOAD_TIMEOUT_MS = 120_000; const UPLOAD_TIMEOUT_MS = 120_000;
@@ -11,13 +16,23 @@ export type UploadFileResult = {
mock: boolean; mock: boolean;
}; };
async function prepareUploadFile(file: File, mediaType: OssMediaType): Promise<File> {
if (mediaType !== 'IMAGE') return file;
const compressed = await compressImageFileIfNeeded(file);
if (compressed.size > DEFAULT_OSS_MAX_UPLOAD_BYTES) {
throw new Error(`图片压缩后仍超过 ${formatOssMaxSizeMb()}MB,请换一张较小的图片`);
}
return compressed;
}
async function uploadFileToOssInner( async function uploadFileToOssInner(
file: File, file: File,
options: { bizType: string; mediaType?: OssMediaType }, options: { bizType: string; mediaType?: OssMediaType },
): Promise<UploadFileResult> { ): Promise<UploadFileResult> {
const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE'); const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE');
const prepared = await prepareUploadFile(file, mediaType);
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', prepared);
formData.append('bizType', options.bizType); formData.append('bizType', options.bizType);
formData.append('mediaType', mediaType); formData.append('mediaType', mediaType);
+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');
} }
+34 -4
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('验证码已发送');
@@ -168,12 +183,27 @@ export default function LoginPage() {
async function login() { async function login() {
if (!ensureAgreed()) return; if (!ensureAgreed()) return;
const trimmedPhone = phone.trim();
const trimmedCode = code.trim();
if (!trimmedPhone) {
setMsg('请输入手机号');
return;
}
if (!/^1\d{10}$/.test(trimmedPhone)) {
setMsg('请输入正确的手机号');
return;
}
if (!trimmedCode) {
setMsg('请输入验证码');
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, code }), body: JSON.stringify({ phone: trimmedPhone, code: trimmedCode }),
silent: true, silent: true,
}); });
saveRememberedSession(data); saveRememberedSession(data);
@@ -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);
+8
View File
@@ -1,3 +1,5 @@
import { reportApiError } from '@dukang/client-logging';
export const apiBase = '/api/v1'; export const apiBase = '/api/v1';
const CLIENT_APP = 'SHOP_H5'; const CLIENT_APP = 'SHOP_H5';
@@ -213,6 +215,12 @@ async function rawRequest<T>(
if (json.code !== 0) { if (json.code !== 0) {
const err = new Error(json.message || '请求失败') as Error & { status?: number }; const err = new Error(json.message || '请求失败') as Error & { status?: number };
err.status = res.status >= 500 ? res.status : json.code; err.status = res.status >= 500 ? res.status : json.code;
if (json.code === 400) {
reportApiError(
{ apiBase, clientApp: CLIENT_APP, getToken: () => localStorage.getItem(ACCESS_TOKEN) },
{ message: json.message || '请求失败', status: 400, url: path, category: 'validation_error' },
);
}
throw err; throw err;
} }
return json.data as T; return json.data as T;
+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;
}
+10 -1
View File
@@ -1,5 +1,10 @@
import { apiBase } from './api'; import { apiBase } from './api';
import { getStoreProfile } from './api'; import { getStoreProfile } from './api';
import {
compressImageFileIfNeeded,
DEFAULT_OSS_MAX_UPLOAD_BYTES,
formatOssMaxSizeMb,
} from '@dukang/shared-ui/compressImage';
const UPLOAD_TIMEOUT_MS = 120_000; const UPLOAD_TIMEOUT_MS = 120_000;
@@ -16,8 +21,12 @@ export type RegisteredResource = {
}; };
export async function uploadFileToOss(file: File, bizType: string): Promise<UploadFileResult> { export async function uploadFileToOss(file: File, bizType: string): Promise<UploadFileResult> {
const compressed = await compressImageFileIfNeeded(file);
if (compressed.size > DEFAULT_OSS_MAX_UPLOAD_BYTES) {
throw new Error(`图片压缩后仍超过 ${formatOssMaxSizeMb()}MB,请换一张较小的图片`);
}
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', compressed);
formData.append('bizType', bizType); formData.append('bizType', bizType);
formData.append('mediaType', 'IMAGE'); formData.append('mediaType', 'IMAGE');
+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;
+13 -3
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);
@@ -140,12 +140,22 @@ export default function LoginPage() {
async function login() { async function login() {
if (!ensureAgreed()) return; if (!ensureAgreed()) return;
const trimmedPhone = phone.trim();
const trimmedCode = code.trim();
if (!trimmedPhone) {
setMsg('phone should not be empty');
return;
}
if (!trimmedCode) {
setMsg('code should not be empty');
return;
}
setLoading(true); setLoading(true);
setMsg(''); setMsg('');
try { try {
const data = await request<ShopSessionPayload>('SHOP_H5', '/shop/auth/login/sms', { const data = await request<ShopSessionPayload>('SHOP_H5', '/shop/auth/login/sms', {
method: 'POST', method: 'POST',
body: JSON.stringify({ phone, code }), body: JSON.stringify({ phone: trimmedPhone, code: trimmedCode }),
}); });
saveRememberedSession(data); saveRememberedSession(data);
applySession(data); applySession(data);
+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);
+8
View File
@@ -1,3 +1,5 @@
import { reportApiError } from '@dukang/client-logging';
export const BRAND = { export const BRAND = {
red: '#A02D30', red: '#A02D30',
yellow: '#FFC107', yellow: '#FFC107',
@@ -83,6 +85,12 @@ async function rawRequest<T>(
if (json.code !== 0) { if (json.code !== 0) {
const err = new Error(json.message || '请求失败') as Error & { status?: number }; const err = new Error(json.message || '请求失败') as Error & { status?: number };
err.status = json.code; err.status = json.code;
if (json.code === 400) {
reportApiError(
{ apiBase, clientApp: CLIENT_APP, getToken: () => localStorage.getItem(ACCESS_TOKEN) },
{ message: json.message || '请求失败', status: 400, url: path, category: 'validation_error' },
);
}
throw err; throw err;
} }
return json.data as T; return json.data as T;
+13 -1
View File
@@ -1,4 +1,9 @@
import { apiBase } from './api'; import { apiBase } from './api';
import {
compressImageFileIfNeeded,
DEFAULT_OSS_MAX_UPLOAD_BYTES,
formatOssMaxSizeMb,
} from '@dukang/shared-ui/compressImage';
export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE'; export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE';
@@ -15,8 +20,15 @@ export async function uploadFileToOss(
options: { bizType: string; mediaType?: OssMediaType }, options: { bizType: string; mediaType?: OssMediaType },
): Promise<UploadFileResult> { ): Promise<UploadFileResult> {
const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE'); const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE');
let prepared = file;
if (mediaType === 'IMAGE') {
prepared = await compressImageFileIfNeeded(file);
if (prepared.size > DEFAULT_OSS_MAX_UPLOAD_BYTES) {
throw new Error(`图片压缩后仍超过 ${formatOssMaxSizeMb()}MB,请换一张较小的图片`);
}
}
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', prepared);
formData.append('bizType', options.bizType); formData.append('bizType', options.bizType);
formData.append('mediaType', mediaType); formData.append('mediaType', mediaType);
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@dukang/mini-user", "name": "@dukang/mini-user",
"version": "0.1.0", "version": "3.4.13",
"private": true, "private": true,
"description": "杜康好客 · C 端用户微信小程序(Taro)", "description": "杜康好客 · C 端用户微信小程序(Taro)",
"scripts": { "scripts": {
+1
View File
@@ -11,6 +11,7 @@ export default defineAppConfig({
'pages/pay/index', 'pages/pay/index',
'pages/orders/index', 'pages/orders/index',
'pages/order-detail/index', 'pages/order-detail/index',
'pages/order-logistics/index',
'pages/pickup-receive/index', 'pages/pickup-receive/index',
'pages/addresses/index', 'pages/addresses/index',
'pages/address-edit/index', 'pages/address-edit/index',
@@ -0,0 +1,44 @@
import { Text } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { splitLogisticsText } from '../lib/order-logistics-utils';
import { toast } from '../lib/api';
type LogisticsRichTextProps = {
text: string;
className?: string;
phoneClassName?: string;
};
export default function LogisticsRichText({
text,
className = '',
phoneClassName = 'order-logistics-phone-link',
}: LogisticsRichTextProps) {
const segments = splitLogisticsText(text);
function dial(phone: string) {
const normalized = phone.replace(/-/g, '');
Taro.makePhoneCall({ phoneNumber: normalized }).catch(() => toast('无法拨打电话'));
}
return (
<Text className={className}>
{segments.map((segment, index) =>
segment.type === 'phone' ? (
<Text
key={`${segment.value}-${index}`}
className={phoneClassName}
onClick={(e) => {
e.stopPropagation();
dial(segment.value);
}}
>
{segment.value}
</Text>
) : (
<Text key={`${index}-text`}>{segment.value}</Text>
),
)}
</Text>
);
}
@@ -20,6 +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 = 'aspectFill';
function previewAt(index: number) { function previewAt(index: number) {
const urls = slides.filter(Boolean); const urls = slides.filter(Boolean);
@@ -41,7 +42,7 @@ export default function ProductCarousel({
<Image <Image
className={`${prefix}-image`} className={`${prefix}-image`}
src={src} src={src}
mode="aspectFill" mode={imageMode}
alt={alt} alt={alt}
onClick={previewable ? () => previewAt(index) : undefined} onClick={previewable ? () => previewAt(index) : undefined}
/> />
@@ -4,6 +4,7 @@ import { useEffect, useRef } from 'react';
import { finishLoginNavigate, forceReloadAfterAccountMerge, goLogin } from '../lib/auth-nav'; import { finishLoginNavigate, forceReloadAfterAccountMerge, goLogin } from '../lib/auth-nav';
import { toast } from '../lib/api'; import { toast } from '../lib/api';
import { capturePromoSceneAndTouchScan } from '../lib/promo'; import { capturePromoSceneAndTouchScan } from '../lib/promo';
import { initClientVersionChecks } from '../lib/client-version';
import { saveWechatLoginResult } from '../lib/pay-wechat'; import { saveWechatLoginResult } from '../lib/pay-wechat';
import { applyWechatShare } from '../lib/wechat-share'; import { applyWechatShare } from '../lib/wechat-share';
import { handleWechatAuthCallback } from '../lib/wechat-auth'; import { handleWechatAuthCallback } from '../lib/wechat-auth';
@@ -38,6 +39,7 @@ export default function WechatShareBootstrap() {
useEffect(() => { useEffect(() => {
void capturePromoSceneAndTouchScan(); void capturePromoSceneAndTouchScan();
initClientVersionChecks();
}, []); }, []);
useEffect(() => { useEffect(() => {
+7
View File
@@ -3,6 +3,7 @@ import { ClientApp } from '@dukang/shared-types';
import { forceReloadAfterAccountMerge } from './auth-nav'; import { forceReloadAfterAccountMerge } from './auth-nav';
import { resetStoresSessionBootstrap } from './stores-session'; import { resetStoresSessionBootstrap } from './stores-session';
import { resetHomeCatalogBootstrap } from './home-catalog-session'; import { resetHomeCatalogBootstrap } from './home-catalog-session';
import { reportClientValidationError } from './client-error';
function resolveApiBase(): string { function resolveApiBase(): string {
const origin = const origin =
@@ -113,6 +114,12 @@ export async function request<T = unknown>(path: string, options: ReqOptions = {
throw new Error('接口不可达,请确认 API 服务已启动'); throw new Error('接口不可达,请确认 API 服务已启动');
} }
if (status >= 400 || body.code !== 0) { if (status >= 400 || body.code !== 0) {
if (status === 400 || body?.code === 400) {
reportClientValidationError({
message: body?.message || `请求失败(${status})`,
apiPath: path,
});
}
throw new Error(body?.message || `请求失败(${status})`); throw new Error(body?.message || `请求失败(${status})`);
} }
return (res.data as { data: T }).data; return (res.data as { data: T }).data;
+12
View File
@@ -6,6 +6,7 @@ export type ClientErrorCategory =
| 'js_error' | 'js_error'
| 'unhandled_rejection' | 'unhandled_rejection'
| 'api_error' | 'api_error'
| 'validation_error'
| 'network' | 'network'
| 'render' | 'render'
| 'bridge' | 'bridge'
@@ -57,6 +58,17 @@ export function reportClientError(payload: ClientErrorPayload): void {
}).catch(() => {}); }).catch(() => {});
} }
/** 上报 API 400 验证错误(失败静默) */
export function reportClientValidationError(input: { message: string; apiPath?: string }): void {
if (input.apiPath?.includes('/common/client-errors')) return;
reportClientError({
level: 'warn',
category: 'validation_error',
message: input.message,
extra: { status: 400, url: input.apiPath },
});
}
let installed = false; let installed = false;
/** 安装小程序/H5 全局未捕获错误钩子(幂等) */ /** 安装小程序/H5 全局未捕获错误钩子(幂等) */
+131
View File
@@ -0,0 +1,131 @@
import Taro from '@tarojs/taro';
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);
}
export function compareSemver(a: string, b: string): number {
const pa = parseSemver(a);
const pb = parseSemver(b);
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i += 1) {
const da = pa[i] ?? 0;
const db = pb[i] ?? 0;
if (da !== db) return da - db;
}
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 enforceMinClientVersion(min);
}
} catch {
/* 配置拉取失败不阻塞启动 */
}
}
export function setupWeappUpdateManager() {
if (process.env.TARO_ENV !== 'weapp') return;
if (typeof Taro.getUpdateManager !== 'function') return;
const manager = Taro.getUpdateManager();
manager.onCheckForUpdate((res) => {
if (!res.hasUpdate) return;
manager.onUpdateReady(() => {
void Taro.showModal({
title: '更新提示',
content: '新版本已准备好,是否重启应用?',
confirmText: '立即更新',
success: (r) => {
if (r.confirm) manager.applyUpdate();
},
});
});
manager.onUpdateFailed(() => {
void Taro.showModal({
title: '更新失败',
content: '新版本下载失败,请删除小程序后重新搜索打开。',
showCancel: false,
});
});
});
}
export function initClientVersionChecks() {
setupWeappUpdateManager();
void checkClientVersionGate();
}
+34
View File
@@ -0,0 +1,34 @@
import Taro from '@tarojs/taro';
const MAX_BYTES = 10 * 1024 * 1024;
/** 小程序临时文件超过 10MB 时用 compressImage 压缩后再上传 */
export async function compressWeappImageIfNeeded(tempFilePath: string): Promise<string> {
if (process.env.TARO_ENV !== 'weapp') return tempFilePath;
let path = tempFilePath;
let size = await getFileSize(path);
if (size <= MAX_BYTES) return path;
const qualities = [80, 65, 50, 40];
for (const quality of qualities) {
const res = await Taro.compressImage({ src: path, quality });
path = res.tempFilePath;
size = await getFileSize(path);
if (size <= MAX_BYTES) return path;
}
if (size > MAX_BYTES) {
throw new Error('图片压缩后仍超过 10MB,请换一张较小的图片');
}
return path;
}
async function getFileSize(filePath: string): Promise<number> {
try {
const info = await Taro.getFileInfo({ filePath });
return typeof info.size === 'number' ? info.size : 0;
} catch {
return 0;
}
}
@@ -69,12 +69,15 @@ export function mergeWxDisplayProfile(profile: UserProfile): UserProfile {
/** 上传 chooseAvatar 临时文件到 OSS,并返回已登记到当前用户的真实资源。 */ /** 上传 chooseAvatar 临时文件到 OSS,并返回已登记到当前用户的真实资源。 */
export async function uploadAvatarTempFile(tempFilePath: string): Promise<UploadedAvatarResource> { export async function uploadAvatarTempFile(tempFilePath: string): Promise<UploadedAvatarResource> {
const { API_BASE, CLIENT_APP, getToken } = await import('./api'); const { API_BASE, CLIENT_APP, getToken } = await import('./api');
const { compressWeappImageIfNeeded } = await import('./compress-image');
const token = getToken(); const token = getToken();
if (!token) throw new Error('请先登录'); if (!token) throw new Error('请先登录');
const filePath = await compressWeappImageIfNeeded(tempFilePath);
const res = await Taro.uploadFile({ const res = await Taro.uploadFile({
url: `${API_BASE}/common/resources/upload`, url: `${API_BASE}/common/resources/upload`,
filePath: tempFilePath, filePath,
name: 'file', name: 'file',
formData: { formData: {
bizType: 'AVATAR', bizType: 'AVATAR',
@@ -0,0 +1,114 @@
import type { OrderTrackDto } from '@dukang/shared-types';
import { isXfxProviderCode } from '@dukang/shared-types';
import { formatShanghaiDateTime } from './datetime';
export type OrderDelivery = {
provider?: string;
trackingNo?: string;
logisticsCompany?: string;
manualQueryUrl?: string;
};
export type OrderTrackNode = {
trackInfo?: string;
createTime?: string;
createdAt?: string;
statusName?: string;
};
export type OrderTrackEstimatedArrival = {
arriveTime: string;
siteName?: string;
};
export type OrderTrackResponse = OrderTrackDto;
/** 订单详情页展示物流摘要的状态(配送中) */
export const ORDER_LOGISTICS_PREVIEW_STATUSES = ['SHIPPING', 'SHIPPED'] as const;
const PHONE_PATTERN = /(1[3-9]\d{9}|\d{3,4}-\d{7,8})/g;
function compareTrackNodeTime(a: OrderTrackNode, b: OrderTrackNode) {
const ta = new Date(a.createTime || a.createdAt || 0).getTime();
const tb = new Date(b.createTime || b.createdAt || 0).getTime();
if (Number.isNaN(ta) && Number.isNaN(tb)) return 0;
if (Number.isNaN(ta)) return 1;
if (Number.isNaN(tb)) return -1;
return ta - tb;
}
export function deliveryProviderLabel(provider?: string, company?: string) {
if (company) return company;
if (!provider || provider === 'MOCK' || isXfxProviderCode(provider)) {
return '小飞侠配送';
}
if (provider === 'LOGISTICS') return '快递配送';
return provider;
}
export function shouldLoadOrderTrack(delivery?: OrderDelivery | null) {
if (!delivery) return false;
if (delivery.trackingNo) return true;
return isXfxProviderCode(delivery.provider || '');
}
export function formatTrackNodeTime(node: OrderTrackNode) {
return formatShanghaiDateTime(node.createTime || node.createdAt);
}
export function sortTrackNodesNewestFirst(nodes: OrderTrackNode[]): OrderTrackNode[] {
return [...nodes].sort((a, b) => -compareTrackNodeTime(a, b));
}
export function sortTrackNodesOldestFirst(nodes: OrderTrackNode[]): OrderTrackNode[] {
return [...nodes].sort(compareTrackNodeTime);
}
export function latestTrackNode(nodes: OrderTrackNode[]) {
if (!nodes.length) return null;
let latest = nodes[0];
for (let i = 1; i < nodes.length; i += 1) {
if (compareTrackNodeTime(nodes[i], latest) > 0) {
latest = nodes[i];
}
}
return latest;
}
export function isLogisticsNotArrived(nodes: OrderTrackNode[], orderStatus?: string) {
if (['PENDING_RECEIVE', 'DELIVERED', 'COMPLETED'].includes(orderStatus || '')) {
return false;
}
if (nodes.length === 0) return true;
return !nodes.some(
(node) => node.statusName?.includes('签收') || node.trackInfo?.includes('签收'),
);
}
export function formatEstimatedArrival(eta?: OrderTrackEstimatedArrival | null) {
if (!eta?.arriveTime) return '';
const time = formatShanghaiDateTime(eta.arriveTime);
if (eta.siteName) return `预计送达:${time}${eta.siteName}`;
return `预计送达:${time}`;
}
export type LogisticsTextSegment = { type: 'text' | 'phone'; value: string };
export function splitLogisticsText(text: string): LogisticsTextSegment[] {
if (!text) return [];
const segments: LogisticsTextSegment[] = [];
let lastIndex = 0;
const regex = new RegExp(PHONE_PATTERN.source, 'g');
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
segments.push({ type: 'text', value: text.slice(lastIndex, match.index) });
}
segments.push({ type: 'phone', value: match[0] });
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
segments.push({ type: 'text', value: text.slice(lastIndex) });
}
return segments.length ? segments : [{ type: 'text', value: text }];
}
@@ -0,0 +1,7 @@
export * from './order-logistics-utils';
import type { OrderTrackResponse } from './order-logistics-utils';
import { request } from './api';
export async function fetchOrderTrack(orderId: string) {
return request<OrderTrackResponse>(`/trade/orders/${orderId}/track`);
}
+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>
@@ -141,6 +141,14 @@ export default function OrderConfirmPickupPage() {
const ready = await ensurePayReady(returnPath); const ready = await ensurePayReady(returnPath);
if (!ready) return; if (!ready) return;
const { confirm } = await Taro.showModal({
title: '确认提交订单',
content: `确认提交现场提货订单?共 ${quantity} 瓶,应付 ¥${Number(preview?.payAmount ?? 0).toFixed(2)}`,
confirmText: '确认提交',
cancelText: '再想想',
});
if (!confirm) return;
setLoading(true); setLoading(true);
setMsg(''); setMsg('');
try { try {
@@ -6,8 +6,19 @@ import SubPageHeader from '../../components/SubPageHeader';
import ShareNavButton from '../../components/ShareNavButton'; import ShareNavButton from '../../components/ShareNavButton';
import WechatShareReady from '../../components/WechatShareReady'; import WechatShareReady from '../../components/WechatShareReady';
import ContactCsButton from '../../components/ContactCsButton'; import ContactCsButton from '../../components/ContactCsButton';
import LogisticsRichText from '../../components/LogisticsRichText';
import { request, toast } from '../../lib/api'; import { request, toast } from '../../lib/api';
import { buildPayUrl } from '../../lib/checkout-nav'; import { buildPayUrl } from '../../lib/checkout-nav';
import {
formatEstimatedArrival,
isLogisticsNotArrived,
latestTrackNode,
ORDER_LOGISTICS_PREVIEW_STATUSES,
shouldLoadOrderTrack,
type OrderTrackEstimatedArrival,
type OrderTrackNode,
} from '../../lib/order-logistics-utils';
import { fetchOrderTrack } from '../../lib/order-logistics';
import { maskPhone } from '../../lib/phone'; import { maskPhone } from '../../lib/phone';
import { confirmOrderReceive, type WechatConfirmPayload } from '../../lib/wechat-order-confirm'; import { confirmOrderReceive, type WechatConfirmPayload } from '../../lib/wechat-order-confirm';
import { import {
@@ -44,8 +55,15 @@ type OrderDetail = {
orderType?: string; orderType?: string;
isProxyOrder?: boolean; isProxyOrder?: boolean;
proxyPartnerName?: string | null; proxyPartnerName?: string | null;
deliveryType?: string;
items?: OrderItem[]; items?: OrderItem[];
wechatConfirm?: WechatConfirmPayload | null; wechatConfirm?: WechatConfirmPayload | null;
delivery?: {
provider?: string;
trackingNo?: string;
logisticsCompany?: string;
manualQueryUrl?: string;
} | null;
}; };
const STATUS_LABELS: Record<string, string> = { const STATUS_LABELS: Record<string, string> = {
@@ -78,11 +96,41 @@ export default function OrderDetailPage() {
usePageView('order_detail_view', orderId ? { orderId } : undefined); usePageView('order_detail_view', orderId ? { orderId } : undefined);
const [order, setOrder] = useState<OrderDetail | null>(null); const [order, setOrder] = useState<OrderDetail | null>(null);
const [confirming, setConfirming] = useState(false); const [confirming, setConfirming] = useState(false);
const [latestTrack, setLatestTrack] = useState<OrderTrackNode | null>(null);
const [estimatedArrival, setEstimatedArrival] = useState<OrderTrackEstimatedArrival | null>(null);
const [trackLoading, setTrackLoading] = useState(false);
async function loadOrderTrack(delivery: OrderDetail['delivery'], orderStatus?: string) {
if (!orderId || !shouldLoadOrderTrack(delivery)) {
setLatestTrack(null);
setEstimatedArrival(null);
return;
}
setTrackLoading(true);
try {
const track = await fetchOrderTrack(orderId);
const nodes = track.nodes ?? [];
setLatestTrack(latestTrackNode(nodes));
setEstimatedArrival(
isLogisticsNotArrived(nodes, orderStatus) && track.estimatedArrival
? track.estimatedArrival
: null,
);
} catch {
setLatestTrack(null);
setEstimatedArrival(null);
} finally {
setTrackLoading(false);
}
}
useEffect(() => { useEffect(() => {
if (!orderId) return; if (!orderId) return;
request<OrderDetail>(`/trade/orders/${orderId}`) request<OrderDetail>(`/trade/orders/${orderId}`)
.then(setOrder) .then((data) => {
setOrder(data);
void loadOrderTrack(data.delivery, data.status);
})
.catch((e) => toast(e instanceof Error ? e.message : '加载失败')); .catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
}, [orderId]); }, [orderId]);
@@ -90,7 +138,10 @@ export default function OrderDetailPage() {
if (!orderId) return; if (!orderId) return;
// 从微信确认收货组件返回后刷新 // 从微信确认收货组件返回后刷新
request<OrderDetail>(`/trade/orders/${orderId}`) request<OrderDetail>(`/trade/orders/${orderId}`)
.then(setOrder) .then((data) => {
setOrder(data);
void loadOrderTrack(data.delivery, data.status);
})
.catch(() => {}); .catch(() => {});
}); });
@@ -99,6 +150,18 @@ export default function OrderDetailPage() {
const canPay = !!order && order.status === 'PENDING_PAY' && !isReship; const canPay = !!order && order.status === 'PENDING_PAY' && !isReship;
const canConfirmReceive = const canConfirmReceive =
!!order && !isReship && !isProxy && ['PENDING_RECEIVE', 'DELIVERED'].includes(order.status || ''); !!order && !isReship && !isProxy && ['PENDING_RECEIVE', 'DELIVERED'].includes(order.status || '');
const canViewLogistics =
!!order &&
order.deliveryType !== 'ON_SITE_PICKUP' &&
!isReship &&
['PENDING_SHIP', 'OUT_WAREHOUSE', 'SHIPPING', 'SHIPPED', 'PENDING_RECEIVE', 'DELIVERED', 'COMPLETED'].includes(
order.status || '',
);
const showLogisticsPreview =
canViewLogistics &&
ORDER_LOGISTICS_PREVIEW_STATUSES.includes(
(order?.status || '') as (typeof ORDER_LOGISTICS_PREVIEW_STATUSES)[number],
);
const item = order?.items?.[0]; const item = order?.items?.[0];
const productName = item?.productName || order?.productName || '杜康商品'; const productName = item?.productName || order?.productName || '杜康商品';
@@ -134,6 +197,11 @@ export default function OrderDetailPage() {
Taro.navigateTo({ url: '/pages/customer-service/index' }); Taro.navigateTo({ url: '/pages/customer-service/index' });
} }
function goLogistics() {
if (!order) return;
Taro.navigateTo({ url: `/pages/order-logistics/index?id=${order.id}` });
}
async function confirmReceive() { async function confirmReceive() {
if (!order || !canConfirmReceive || confirming) return; if (!order || !canConfirmReceive || confirming) return;
@@ -211,6 +279,34 @@ export default function OrderDetailPage() {
{isProxy && order.proxyPartnerName ? ( {isProxy && order.proxyPartnerName ? (
<Text className="order-proxy-hint"> {order.proxyPartnerName} </Text> <Text className="order-proxy-hint"> {order.proxyPartnerName} </Text>
) : null} ) : null}
{showLogisticsPreview ? (
<View className="order-logistics-preview-block">
<View className="order-logistics-preview" onClick={goLogistics}>
<View className="order-logistics-preview-main">
{trackLoading ? (
<Text className="order-logistics-preview-info u-muted"></Text>
) : latestTrack?.trackInfo ? (
<LogisticsRichText
text={latestTrack.trackInfo}
className="order-logistics-preview-info"
/>
) : order.delivery?.manualQueryUrl ? (
<Text className="order-logistics-preview-info u-muted"></Text>
) : (
<Text className="order-logistics-preview-info u-muted"></Text>
)}
</View>
<Text className="order-logistics-preview-link"></Text>
</View>
{estimatedArrival ? (
<Text className="order-logistics-eta">{formatEstimatedArrival(estimatedArrival)}</Text>
) : null}
</View>
) : canViewLogistics ? (
<Text className="order-logistics-link" onClick={goLogistics}>
</Text>
) : null}
</View> </View>
<View className="order-card"> <View className="order-card">
<Text className="order-card-title"></Text> <Text className="order-card-title"></Text>
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationStyle: 'custom',
navigationBarTitleText: '物流详情',
});
@@ -0,0 +1,209 @@
import { useEffect, useMemo, useState } from 'react';
import { View, Text, Image } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import LogisticsRichText from '../../components/LogisticsRichText';
import { request, toast } from '../../lib/api';
import {
deliveryProviderLabel,
formatTrackNodeTime,
shouldLoadOrderTrack,
sortTrackNodesOldestFirst,
type OrderDelivery,
type OrderTrackNode,
} from '../../lib/order-logistics-utils';
import { fetchOrderTrack } from '../../lib/order-logistics';
import { usePageView } from '../../lib/usePageView';
type OrderDetail = {
id: string;
orderNo?: string;
status?: string;
delivery?: OrderDelivery | null;
};
export default function OrderLogisticsPage() {
const router = useRouter();
const orderId = router.params.id ?? '';
usePageView('order_logistics_view', orderId ? { orderId } : undefined);
const [order, setOrder] = useState<OrderDetail | null>(null);
const [trackNodes, setTrackNodes] = useState<OrderTrackNode[]>([]);
const [signPhotoUrls, setSignPhotoUrls] = useState<string[]>([]);
const [trackLoading, setTrackLoading] = useState(false);
const sortedTrackNodes = useMemo(
() => (Array.isArray(trackNodes) ? sortTrackNodesOldestFirst(trackNodes) : []),
[trackNodes],
);
const timelineComplete = ['PENDING_RECEIVE', 'DELIVERED', 'COMPLETED'].includes(
order?.status || '',
);
useEffect(() => {
if (!orderId) return;
let cancelled = false;
(async () => {
try {
const data = await request<OrderDetail>(`/trade/orders/${orderId}`);
if (cancelled) return;
setOrder(data);
if (!shouldLoadOrderTrack(data.delivery)) {
setTrackNodes([]);
setSignPhotoUrls([]);
return;
}
setTrackLoading(true);
try {
const track = await fetchOrderTrack(orderId);
if (!cancelled) {
setTrackNodes(track.nodes ?? []);
setSignPhotoUrls(track.signPhotoUrls ?? []);
}
} catch {
if (!cancelled) {
setTrackNodes([]);
setSignPhotoUrls([]);
}
} finally {
if (!cancelled) setTrackLoading(false);
}
} catch (e) {
if (!cancelled) toast(e instanceof Error ? e.message : '加载失败');
}
})();
return () => {
cancelled = true;
};
}, [orderId]);
function openManualQuery() {
const url = order?.delivery?.manualQueryUrl?.trim();
if (!url) {
toast('暂无物流查询链接');
return;
}
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined') {
window.open(url, '_blank');
return;
}
Taro.setClipboardData({ data: url })
.then(() => toast('查询链接已复制,请在浏览器中打开'))
.catch(() => toast('无法打开物流查询'));
}
function previewSignPhotos(index: number) {
if (signPhotoUrls.length === 0) return;
Taro.previewImage({ current: signPhotoUrls[index], urls: signPhotoUrls }).catch(() =>
toast('无法预览图片'),
);
}
const delivery = order?.delivery;
return (
<PageShell variant="sub" className="order-logistics-page">
<SubPageHeader title="物流详情" />
<View className="sub-page-body order-logistics-body">
{!order ? (
<View className="u-empty"></View>
) : (
<>
<View className="order-card order-logistics-info-card">
<Text className="order-card-title"></Text>
<View className="order-row">
<Text className="order-row-label"></Text>
<Text className="order-row-value">
{deliveryProviderLabel(delivery?.provider, delivery?.logisticsCompany)}
</Text>
</View>
{delivery?.trackingNo ? (
<View className="order-row">
<Text className="order-row-label"></Text>
<Text className="order-row-value">{delivery.trackingNo}</Text>
</View>
) : null}
{delivery?.manualQueryUrl ? (
<View className="order-row">
<Text className="order-row-label"></Text>
<Text className="order-row-value order-row-value--link" onClick={openManualQuery}>
</Text>
</View>
) : null}
{!delivery?.trackingNo && !delivery?.manualQueryUrl ? (
<Text className="u-muted"></Text>
) : null}
</View>
{signPhotoUrls.length > 0 ? (
<View className="order-card">
<Text className="order-card-title"></Text>
<View className="order-logistics-sign-photos">
{signPhotoUrls.map((url, index) => (
<Image
key={url}
className="order-logistics-sign-photo"
src={url}
mode="aspectFill"
onClick={() => previewSignPhotos(index)}
/>
))}
</View>
</View>
) : null}
{trackLoading ? (
<View className="order-card">
<Text className="order-card-title"></Text>
<Text className="u-muted"></Text>
</View>
) : sortedTrackNodes.length > 0 ? (
<View className="order-card">
<Text className="order-card-title"></Text>
<View
className={`order-logistics-timeline${
timelineComplete ? ' order-logistics-timeline--complete' : ''
}`}
>
{sortedTrackNodes.map((node, index) => {
const isLatest = index === sortedTrackNodes.length - 1;
return (
<View
key={`${node.trackInfo}-${node.createTime}-${index}`}
className={[
'order-logistics-timeline-item',
'order-logistics-timeline-item--done',
isLatest ? 'order-logistics-timeline-item--latest' : '',
]
.filter(Boolean)
.join(' ')}
>
<View className="order-logistics-timeline-dot" />
<View className="order-logistics-timeline-body">
{node.statusName ? (
<Text className="order-logistics-timeline-status">{node.statusName}</Text>
) : null}
<LogisticsRichText
text={node.trackInfo || '—'}
className="order-logistics-node-info"
/>
<Text className="order-logistics-node-time">{formatTrackNodeTime(node)}</Text>
</View>
</View>
);
})}
</View>
</View>
) : shouldLoadOrderTrack(delivery) ? (
<View className="order-card">
<Text className="order-card-title"></Text>
<Text className="u-muted"></Text>
</View>
) : null}
</>
)}
</View>
</PageShell>
);
}
@@ -11,7 +11,6 @@ import type { ProductDetailContentDto } from '@dukang/shared-types';
import PageShell from '../../components/PageShell'; import PageShell from '../../components/PageShell';
import PageNavBar from '../../components/PageNavBar'; import PageNavBar from '../../components/PageNavBar';
import ProductCarousel from '../../components/ProductCarousel'; import ProductCarousel from '../../components/ProductCarousel';
import ShareNavButton from '../../components/ShareNavButton';
import WechatShareReady from '../../components/WechatShareReady'; import WechatShareReady from '../../components/WechatShareReady';
import { goLogin } from '../../lib/auth-nav'; import { goLogin } from '../../lib/auth-nav';
import { ensurePayReady } from '../../lib/pay-ready'; import { ensurePayReady } from '../../lib/pay-ready';
@@ -167,12 +166,11 @@ export default function ProductDetailPage() {
solid={headerSolid} solid={headerSolid}
titleVisible={headerSolid} titleVisible={headerSolid}
onBack={goBack} onBack={goBack}
right={<ShareNavButton payload={sharePayload} />}
/> />
<View className="product-detail-main"> <View className="product-detail-main">
<View className="product-detail-hero full-bleed"> <View className="product-detail-hero full-bleed">
<ProductCarousel images={carouselImages} alt={product.name} variant="detail" /> <ProductCarousel images={carouselImages} alt={product.name} variant="detail" previewable />
</View> </View>
<View className="product-detail-info"> <View className="product-detail-info">
+50 -15
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,
@@ -15,6 +15,8 @@ import ShareNavButton from '../../components/ShareNavButton';
import StoreRedeemMarquee from '../../components/StoreRedeemMarquee'; import StoreRedeemMarquee from '../../components/StoreRedeemMarquee';
import WechatShareReady from '../../components/WechatShareReady'; import WechatShareReady from '../../components/WechatShareReady';
import { request, toast } from '../../lib/api'; import { request, toast } from '../../lib/api';
import { maskPhone } from '../../lib/phone';
import { track } from '../../lib/analytics';
import { formatShanghaiDateTime } from '../../lib/datetime'; import { formatShanghaiDateTime } from '../../lib/datetime';
import { import {
DEFAULT_SHARE_DESC, DEFAULT_SHARE_DESC,
@@ -154,9 +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 [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);
}); });
@@ -271,6 +278,7 @@ export default function StoreDetailPage() {
toast('暂无门店电话'); toast('暂无门店电话');
return; return;
} }
track('store_phone_call', { storeId: store.id });
Taro.makePhoneCall({ phoneNumber: store.phone }).catch(() => toast('无法拨打电话')); Taro.makePhoneCall({ phoneNumber: store.phone }).catch(() => toast('无法拨打电话'));
} }
@@ -317,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() || '';
@@ -374,7 +384,9 @@ export default function StoreDetailPage() {
{store.phone ? ( {store.phone ? (
<View className="store-detail-row"> <View className="store-detail-row">
<Text className="store-detail-meta store-detail-meta--flex">: {store.phone}</Text> <Text className="store-detail-meta store-detail-meta--flex">
: {maskPhone(store.phone)}
</Text>
<Text className="store-detail-action" onClick={callStore}> <Text className="store-detail-action" onClick={callStore}>
</Text> </Text>
@@ -396,26 +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 ? (
<View key={`${pkg.name}-${index}`} className="store-detail-package-card"> <ScrollView className="store-detail-package-tabs" scrollX showScrollbar={false} enhanced>
{pkg.imageUrl ? ( <View className="store-detail-package-tabs-inner">
<Image className="store-detail-package-img" src={pkg.imageUrl} mode="aspectFill" /> {packages.map((pkg, index) => (
<View
key={`${pkg.name}-${index}`}
className={`store-detail-package-tab${
index === activePackageIndex ? ' store-detail-package-tab--active' : ''
}`}
onClick={() => setActivePackageIndex(index)}
>
<Text className="store-detail-package-tab-text">{pkg.name}</Text>
</View>
))}
</View>
</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} ) : null}
<Text className="store-detail-package-name">{pkg.name}</Text>
<Text className="store-detail-package-body"> <Text className="store-detail-package-body">
{formatRedeemAmountYuan(pkg.price)} · {pkg.dishes} {formatRedeemAmountYuan(activePackage.price)} · {activePackage.dishes}
</Text> </Text>
{pkg.usableTime ? ( {activePackage.usableTime ? (
<Text className="store-detail-package-meta">使{pkg.usableTime}</Text> <Text className="store-detail-package-meta">使{activePackage.usableTime}</Text>
) : null} ) : null}
{pkg.otherNotes ? ( {activePackage.otherNotes ? (
<Text className="store-detail-package-meta">{pkg.otherNotes}</Text> <Text className="store-detail-package-meta">{activePackage.otherNotes}</Text>
) : null} ) : null}
</View> </View>
))} </View>
</View> </View>
) : null} ) : null}
+27 -14
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { View, Text, Image, Input } from '@tarojs/components'; import { View, Text, Image, Input } from '@tarojs/components';
import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } from '@tarojs/taro'; import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
import { StoreStatus, STORE_STATUS_LABELS } from '@dukang/shared-types';
import PageShell from '../../components/PageShell'; import PageShell from '../../components/PageShell';
import TabMainHeader from '../../components/TabMainHeader'; import TabMainHeader from '../../components/TabMainHeader';
import WechatShareReady from '../../components/WechatShareReady'; import WechatShareReady from '../../components/WechatShareReady';
@@ -348,6 +349,12 @@ export default function StoresPage() {
return parts.length ? `营业时间: ${parts.join('')}` : '营业时间: 10:00-22:00'; return parts.length ? `营业时间: ${parts.join('')}` : '营业时间: 10:00-22:00';
} }
function formatStatus(store: Store) {
const status = store.status as StoreStatus | undefined;
if (status && STORE_STATUS_LABELS[status]) return STORE_STATUS_LABELS[status];
return STORE_STATUS_LABELS[StoreStatus.OPEN];
}
const sharePayload = useMemo( const sharePayload = useMemo(
() => ({ () => ({
title: '杜康好客门店', title: '杜康好客门店',
@@ -427,31 +434,37 @@ export default function StoresPage() {
{s.coverUrl ? ( {s.coverUrl ? (
<Image className="store-card-cover" src={s.coverUrl} mode="aspectFill" /> <Image className="store-card-cover" src={s.coverUrl} mode="aspectFill" />
) : ( ) : (
<View className="store-card-cover--empty" /> <View className="store-card-cover store-card-cover--empty" />
)} )}
<View className="store-card-body"> <View className="store-card-body">
<Text className="store-card-name">{s.name}</Text> {/* 第1行:标题 + 距离 */}
<Text className="store-card-meta"> <View className="store-card-row store-card-row--head">
{s.district ? `${s.district} · ` : ''} <Text className="store-card-name" numberOfLines={1}>
{s.address || '地址待完善'} {s.name}
</Text> </Text>
<Text className="store-card-meta">{formatHours(s)}</Text>
{s.avgPrice != null && Number(s.avgPrice) > 0 ? (
<Text className="store-card-meta">¥{Number(s.avgPrice).toFixed(0)}</Text>
) : null}
<View className="store-card-footer">
<Text className="store-card-distance"> <Text className="store-card-distance">
{formatDistanceMeters(s.distanceMeters)} {formatDistanceMeters(s.distanceMeters)}
</Text> </Text>
<Text </View>
{/* 第2行:状态 + 营业时间(含第二段) */}
<View className="store-card-row store-card-row--meta">
<Text className="store-card-status">{formatStatus(s)}</Text>
<Text className="store-card-hours">{formatHours(s)}</Text>
</View>
{/* 第3行:地址 + 去核销 */}
<View className="store-card-row store-card-row--foot">
<Text className="store-card-address" numberOfLines={1}>
{s.address || (s.district ? `${s.district}` : '地址待完善')}
</Text>
<View
className="store-card-cta" className="store-card-cta"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
Taro.navigateTo({ url: '/pages/redeem/index' }); Taro.navigateTo({ url: '/pages/redeem/index' });
}} }}
> >
<Text className="store-card-cta-text"></Text>
</Text> </View>
</View> </View>
</View> </View>
</View> </View>
+185
View File
@@ -488,3 +488,188 @@
line-height: 1.5; line-height: 1.5;
text-align: center; text-align: center;
} }
.order-logistics-page {
background: var(--color-background);
min-height: 100vh;
}
.order-logistics-body {
padding-top: 0;
}
.order-logistics-info-card {
margin-top: 0;
}
.order-logistics-link {
display: inline-block;
margin-top: 8px;
font-size: 14px;
color: var(--color-heritage-red);
}
.order-logistics-preview {
display: flex;
align-items: center;
gap: 12px;
margin-top: 12px;
padding: 12px;
border-radius: 10px;
background: rgba(166, 29, 36, 0.04);
}
.order-logistics-preview-main {
flex: 1;
min-width: 0;
}
.order-logistics-preview-info {
font-size: 13px;
color: var(--color-on-surface);
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.order-logistics-preview-link {
flex-shrink: 0;
font-size: 13px;
font-weight: 600;
color: var(--color-heritage-red);
}
.order-logistics-preview-block {
margin-top: 12px;
}
.order-logistics-eta {
display: block;
margin-top: 8px;
font-size: 12px;
color: var(--color-on-surface-variant);
line-height: 1.5;
}
.order-logistics-phone-link {
color: var(--color-heritage-red);
}
.order-logistics-sign-photos {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.order-logistics-sign-photo {
width: 96px;
height: 96px;
border-radius: 8px;
background: var(--color-surface-container);
}
.order-row-value--link {
color: var(--color-heritage-red);
}
.order-logistics-timeline {
padding-top: 4px;
}
.order-logistics-timeline--complete .order-logistics-timeline-item:not(:last-child)::before {
background: rgba(166, 29, 36, 0.35);
}
.order-logistics-timeline-item {
display: flex;
gap: 12px;
position: relative;
padding-bottom: 16px;
}
.order-logistics-timeline-item:last-child {
padding-bottom: 0;
}
.order-logistics-timeline-item:not(:last-child)::before {
content: '';
position: absolute;
left: 5px;
top: 14px;
bottom: 0;
width: 1px;
background: rgba(0, 0, 0, 0.08);
}
.order-logistics-timeline-item--done .order-logistics-timeline-dot {
background: var(--color-heritage-red);
box-shadow: 0 0 0 1px var(--color-heritage-red);
}
.order-logistics-timeline-item--done:not(:last-child)::before {
background: rgba(166, 29, 36, 0.35);
}
.order-logistics-timeline-dot {
flex-shrink: 0;
width: 11px;
height: 11px;
margin-top: 4px;
border-radius: 50%;
background: var(--color-surface-container-high, #e8e4df);
border: 2px solid #fff;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08);
}
.order-logistics-timeline-item--latest .order-logistics-timeline-dot {
background: var(--color-heritage-red);
box-shadow: 0 0 0 2px rgba(166, 29, 36, 0.25);
}
.order-logistics-timeline-body {
flex: 1;
min-width: 0;
}
.order-logistics-timeline-status {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--color-on-surface);
margin-bottom: 4px;
}
.order-logistics-timeline-item--done .order-logistics-timeline-status {
color: var(--color-heritage-red);
}
.order-logistics-timeline-item--latest .order-logistics-timeline-status {
color: var(--color-heritage-red);
font-weight: 700;
}
.order-logistics-node {
padding: 10px 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.order-logistics-node:last-child {
border-bottom: none;
}
.order-logistics-node-time {
display: block;
font-size: 12px;
color: var(--color-on-surface-variant);
margin-bottom: 4px;
}
.order-logistics-node-info {
display: block;
font-size: 14px;
color: var(--color-on-surface);
line-height: 1.5;
}
+89 -22
View File
@@ -10,8 +10,9 @@
.store-detail-carousel-wrap { .store-detail-carousel-wrap {
position: relative; position: relative;
width: 100%; width: 100%;
height: 280px; aspect-ratio: 4 / 3;
overflow: hidden; overflow: hidden;
background: var(--color-surface-container);
} }
.store-detail-carousel { .store-detail-carousel {
@@ -26,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);
} }
@@ -175,15 +181,14 @@
} }
.store-detail-env-grid { .store-detail-env-grid {
display: grid; display: flex;
grid-template-columns: repeat(2, minmax(0, 1fr)); flex-direction: column;
gap: 12px; gap: 12px;
} }
.store-detail-env-item { .store-detail-env-item {
position: relative; position: relative;
width: 100%; width: 100%;
aspect-ratio: 4 / 3;
border-radius: var(--radius-md, 8px); border-radius: var(--radius-md, 8px);
overflow: hidden; overflow: hidden;
background: var(--color-surface-container); background: var(--color-surface-container);
@@ -192,8 +197,9 @@
.store-detail-env-img { .store-detail-env-img {
width: 100%; width: 100%;
height: 100%; height: auto;
display: block; display: block;
vertical-align: top;
} }
.store-detail-bar { .store-detail-bar {
@@ -236,41 +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-img { .store-detail-package-tabs {
width: 100%; width: 100%;
height: 160px; margin-bottom: 10px;
border-radius: var(--radius-md, 8px); white-space: nowrap;
margin-bottom: 8px;
display: block;
} }
.store-detail-package-card:last-of-type { .store-detail-package-tabs-inner {
border-bottom: none; 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-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;
} }
+109 -29
View File
@@ -144,63 +144,143 @@
padding: 4px var(--space-page) 16px; padding: 4px var(--space-page) 16px;
} }
/* 左图右文,卡片等高 */
.store-card { .store-card {
display: flex;
flex-direction: row;
align-items: stretch;
gap: 12px;
padding: 12px;
box-sizing: border-box;
background: var(--color-card); background: var(--color-card);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-card); box-shadow: var(--shadow-card);
margin-bottom: 16px; margin-bottom: 12px;
} }
.store-card-cover { .store-card-cover {
width: 100%; flex-shrink: 0;
height: 160px; width: 96px;
height: 96px;
border-radius: 8px;
background: var(--color-surface-container); background: var(--color-surface-container);
display: block; display: block;
} }
.store-card-cover--empty { .store-card-cover--empty {
height: 160px;
background: var(--color-surface-container); background: var(--color-surface-container);
} }
.store-card-body { .store-card-body {
padding: 14px 16px 16px; flex: 1;
min-width: 0;
height: 96px;
display: flex;
flex-direction: column;
justify-content: space-between;
box-sizing: border-box;
}
.store-card-row {
display: flex;
align-items: center;
min-width: 0;
overflow: hidden;
}
/* 第1行:加粗标题(单行截断)+ 右对齐距离 */
.store-card-row--head {
gap: 8px;
height: 22px;
} }
.store-card-name { .store-card-name {
display: block; flex: 1;
min-width: 0;
font-family: var(--font-headline); font-family: var(--font-headline);
font-size: 16px; font-size: 15px;
font-weight: 700; font-weight: 700;
color: var(--color-on-surface); line-height: 22px;
margin-bottom: 6px; color: #1a1a1a;
} overflow: hidden;
text-overflow: ellipsis;
.store-card-meta { white-space: nowrap;
display: block;
font-size: 12px;
color: var(--color-subtle-gray);
margin-bottom: 4px;
}
.store-card-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 12px;
} }
.store-card-distance { .store-card-distance {
flex-shrink: 0;
max-width: 40%;
font-size: 12px; font-size: 12px;
color: var(--color-on-surface-variant); font-weight: 400;
line-height: 22px;
color: #999;
text-align: right;
white-space: nowrap;
}
/* 第2行:营业状态 + 营业时间(可含两段) */
.store-card-row--meta {
gap: 6px;
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;
font-size: 11px;
font-weight: 600;
line-height: 18px;
white-space: nowrap;
}
.store-card-hours {
flex: 1;
min-width: 0;
font-size: 11px;
font-weight: 400;
line-height: 16px;
color: #999;
}
/* 第3行:地址(单行截断)+ 右对齐去核销 */
.store-card-row--foot {
gap: 8px;
height: 28px;
}
.store-card-address {
flex: 1;
min-width: 0;
font-size: 9px;
font-weight: 400;
line-height: 28px;
color: #999;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.store-card-cta { .store-card-cta {
padding: 6px 14px; flex-shrink: 0;
border-radius: var(--radius-full); display: flex;
background: rgba(166, 29, 36, 0.08); align-items: center;
color: var(--color-heritage-red); justify-content: center;
padding: 0 14px;
height: 26px;
border-radius: 999px;
background: var(--color-heritage-red, #a61d24);
box-sizing: border-box;
}
.store-card-cta-text {
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
line-height: 26px;
color: #fff;
white-space: nowrap;
} }
+3 -1
View File
@@ -5,6 +5,7 @@ export type ClientErrorCategory =
| 'js_error' | 'js_error'
| 'unhandled_rejection' | 'unhandled_rejection'
| 'api_error' | 'api_error'
| 'validation_error'
| 'network' | 'network'
| 'render' | 'render'
| 'bridge' | 'bridge'
@@ -73,6 +74,7 @@ export function reportApiError(
input: { message: string; status?: number; url?: string; category?: ClientErrorCategory }, input: { message: string; status?: number; url?: string; category?: ClientErrorCategory },
) { ) {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
if (input.url?.includes('/common/client-errors')) return;
const token = opts.getToken?.() ?? localStorage.getItem('accessToken'); const token = opts.getToken?.() ?? localStorage.getItem('accessToken');
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -85,7 +87,7 @@ export function reportApiError(
headers, headers,
body: { body: {
level: 'warn', level: 'warn',
category: input.category ?? 'api_error', category: input.category ?? (input.status === 400 ? 'validation_error' : 'api_error'),
message: input.message.slice(0, 1000), message: input.message.slice(0, 1000),
pagePath: window.location.pathname, pagePath: window.location.pathname,
clientApp: opts.clientApp, clientApp: opts.clientApp,
@@ -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: [],
}; };
/** 技术支持工单批量/人工改状态校验(禁止回退) */ /** 技术支持工单批量/人工改状态校验(禁止回退) */
@@ -50,6 +50,20 @@ export type CreateKnowledgeDocumentRequest = {
sizeBytes?: number | null; sizeBytes?: number | null;
}; };
export type UpdateKnowledgeDocumentRequest = {
title?: string;
contentText?: string | null;
fileName?: string | null;
fileUrl?: string | null;
mimeType?: string | null;
sizeBytes?: number | null;
};
/** 编辑文档时含正文 */
export type KnowledgeDocumentDetailDto = KnowledgeDocumentDto & {
contentText: string | null;
};
export type KnowledgeBaseOptionDto = { export type KnowledgeBaseOptionDto = {
id: string; id: string;
name: string; name: string;
+44
View File
@@ -85,6 +85,50 @@ export function promoConversion(scan: number, orders: number): string {
return `${Math.round((orders / scan) * 1000) / 10}%`; 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 { export function buildPromoLandingUrl(baseUrl: string, code: string, qrcodeId: string): string {
const base = baseUrl.replace(/\/$/, ''); const base = baseUrl.replace(/\/$/, '');
return `${base}/?promo=${encodeURIComponent(code)}&pid=${encodeURIComponent(qrcodeId)}`; return `${base}/?promo=${encodeURIComponent(code)}&pid=${encodeURIComponent(qrcodeId)}`;
+1 -1
View File
@@ -183,7 +183,7 @@ export const STORE_SETTLEMENT_DEFAULT_RATE = 0.6;
/** 酒厂账单结算比例(酒单实付 × 比例),暂定 30% */ /** 酒厂账单结算比例(酒单实付 × 比例),暂定 30% */
export const WINERY_SETTLEMENT_RATE = 0.3; export const WINERY_SETTLEMENT_RATE = 0.3;
/** 酒厂账单出账滞后自然日(T+3:支付日 + 3 天后纳入账单) */ /** 酒厂账单出账滞后自然日(T+3:订单完成日 + 3 天后纳入账单) */
export const WINERY_SETTLEMENT_LAG_DAYS = 3; export const WINERY_SETTLEMENT_LAG_DAYS = 3;
export type { LogisticsSettlementMethod } from './enums'; export type { LogisticsSettlementMethod } from './enums';
+21 -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,19 @@ export const SUPPORT_TICKET_STATUS_LABELS: Record<SupportTicketStatusDto, string
DEVELOPING: '开发', DEVELOPING: '开发',
TESTING: '测试', TESTING: '测试',
PASSED: '通过', PASSED: '通过',
PUBLISHED: '已发布',
};
/** 技术支持工单优先级 */
export type SupportTicketPriorityDto = 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT';
export const SUPPORT_TICKET_PRIORITIES = ['LOW', 'NORMAL', 'HIGH', 'URGENT'] as const;
export const SUPPORT_TICKET_PRIORITY_LABELS: Record<SupportTicketPriorityDto, string> = {
LOW: '低',
NORMAL: '普通',
HIGH: '高',
URGENT: '紧急',
}; };
export interface SupportTicketDto { export interface SupportTicketDto {
@@ -38,6 +53,7 @@ export interface SupportTicketDto {
ticketNo: string; ticketNo: string;
ticketType: SupportTicketTypeDto; ticketType: SupportTicketTypeDto;
status: SupportTicketStatusDto; status: SupportTicketStatusDto;
priority: SupportTicketPriorityDto;
title: string; title: string;
content?: string | null; content?: string | null;
rejectReason?: string | null; rejectReason?: string | null;
@@ -51,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 {
@@ -59,6 +77,7 @@ export interface CreateSupportTicketRequest {
content?: string; content?: string;
remark?: string; remark?: string;
attachmentUrls?: string[]; attachmentUrls?: string[];
priority?: SupportTicketPriorityDto;
} }
export interface UpdateSupportTicketRequest { export interface UpdateSupportTicketRequest {
@@ -67,6 +86,7 @@ export interface UpdateSupportTicketRequest {
content?: string; content?: string;
remark?: string; remark?: string;
attachmentUrls?: string[]; attachmentUrls?: string[];
priority?: SupportTicketPriorityDto;
} }
export interface RejectSupportTicketRequest { export interface RejectSupportTicketRequest {
+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;
+24
View File
@@ -70,6 +70,30 @@ export interface DeliveryDto {
deliveredAt?: string; deliveredAt?: string;
} }
/** 物流路由节点(C 端 track API */
export interface OrderTrackNodeDto {
trackInfo: string;
createTime: string;
statusName?: string;
}
/** 小飞侠 cmd=100301 预估送达 */
export interface OrderTrackEstimatedArrivalDto {
arriveTime: string;
siteName?: string;
}
/** GET /trade/orders/:id/track 响应 */
export interface OrderTrackDto {
nodes: OrderTrackNodeDto[];
signPhotoUrls: string[];
estimatedArrival?: OrderTrackEstimatedArrivalDto | null;
manualQueryUrl?: string | null;
provider?: string;
trackingNo?: string | null;
logisticsCompany?: string | null;
}
/** 大单拦截原因:≥10 箱不自动推小飞侠 */ /** 大单拦截原因:≥10 箱不自动推小飞侠 */
export const FULFILLMENT_HOLD_REASON_LABELS: Record<string, string> = { export const FULFILLMENT_HOLD_REASON_LABELS: Record<string, string> = {
LARGE_ORDER_GE_10_BOXES: '大单≥10箱,待总部确认推单/自配送', LARGE_ORDER_GE_10_BOXES: '大单≥10箱,待总部确认推单/自配送',
+2
View File
@@ -48,6 +48,8 @@ export type ClientRuntimeConfig = {
banners: string[]; banners: string[];
footerUrl: string | null; footerUrl: string | null;
}; };
/** 小程序最低兼容版本(semver,如 3.4.13);客户端低于此值时提示更新 */
minClientVersion?: string | null;
}; };
/** 是否展示微信授权入口 */ /** 是否展示微信授权入口 */
+2 -1
View File
@@ -11,7 +11,8 @@
"./CouponBadge": "./src/CouponBadge.tsx", "./CouponBadge": "./src/CouponBadge.tsx",
"./OrderStatusTabs": "./src/OrderStatusTabs.tsx", "./OrderStatusTabs": "./src/OrderStatusTabs.tsx",
"./AppImage": "./src/AppImage.tsx", "./AppImage": "./src/AppImage.tsx",
"./PullToRefresh": "./src/PullToRefresh.tsx" "./PullToRefresh": "./src/PullToRefresh.tsx",
"./compressImage": "./src/compressImage.ts"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^18.3.1" "react": "^18.3.1"
+90
View File
@@ -0,0 +1,90 @@
/** OSS 单文件上限(与后端 OSS_MAX_UPLOAD_BYTES 默认一致) */
export const DEFAULT_OSS_MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
function isCompressibleImage(file: File): boolean {
if (!file.type.startsWith('image/')) return false;
if (file.type === 'image/svg+xml' || file.type === 'image/gif') return false;
return true;
}
function loadImage(file: File): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
resolve(img);
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('图片读取失败'));
};
img.src = url;
});
}
function canvasToBlob(canvas: HTMLCanvasElement, type: string, quality: number): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error('图片压缩失败'))),
type,
quality,
);
});
}
async function renderScaled(
img: HTMLImageElement,
maxEdge: number,
quality: number,
mime: string,
): Promise<Blob> {
const w = img.naturalWidth;
const h = img.naturalHeight;
const scale = Math.min(1, maxEdge / Math.max(w, h));
const cw = Math.max(1, Math.round(w * scale));
const ch = Math.max(1, Math.round(h * scale));
const canvas = document.createElement('canvas');
canvas.width = cw;
canvas.height = ch;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('图片压缩失败');
ctx.drawImage(img, 0, 0, cw, ch);
return canvasToBlob(canvas, mime, quality);
}
/**
* 超过 maxBytes 的图片用 canvas 压缩后再上传;不可压缩或非图片原样返回。
*/
export async function compressImageFileIfNeeded(
file: File,
maxBytes = DEFAULT_OSS_MAX_UPLOAD_BYTES,
): Promise<File> {
if (!isCompressibleImage(file) || file.size <= maxBytes) return file;
const img = await loadImage(file);
const mime = file.type === 'image/png' || file.type === 'image/webp' ? 'image/jpeg' : file.type || 'image/jpeg';
const edges = [4096, 3072, 2048, 1536, 1280, 1024, 800];
let best: Blob | null = null;
outer: for (const edge of edges) {
for (let quality = 0.92; quality >= 0.5; quality -= 0.08) {
const blob = await renderScaled(img, edge, quality, mime);
if (!best || blob.size < best.size) best = blob;
if (blob.size <= maxBytes) {
best = blob;
break outer;
}
}
}
if (!best || best.size >= file.size) return file;
const ext = mime === 'image/png' ? '.png' : '.jpg';
const baseName = file.name.replace(/\.[^.]+$/, '') || 'image';
return new File([best], `${baseName}${ext}`, { type: mime, lastModified: Date.now() });
}
export function formatOssMaxSizeMb(maxBytes = DEFAULT_OSS_MAX_UPLOAD_BYTES): number {
return Math.floor(maxBytes / 1024 / 1024);
}
+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();
+3
View File
@@ -29,6 +29,9 @@ importers:
'@dukang/shared-types': '@dukang/shared-types':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/shared-types version: link:../../packages/shared-types
'@dukang/shared-ui':
specifier: workspace:*
version: link:../../packages/shared-ui
antd: antd:
specifier: ^5.22.0 specifier: ^5.22.0
version: 5.29.3(luxon@3.7.2)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 5.29.3(luxon@3.7.2)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+39
View File
@@ -107,6 +107,14 @@ enum SupportTicketStatus {
DEVELOPING DEVELOPING
TESTING TESTING
PASSED PASSED
PUBLISHED
}
enum SupportTicketPriority {
LOW
NORMAL
HIGH
URGENT
} }
enum DevPlanTaskType { enum DevPlanTaskType {
@@ -174,6 +182,13 @@ enum PromoCodeScene {
OTHER OTHER
} }
enum PromoMetricEventType {
SCAN
ATTRIBUTION
REGISTER
ORDER
}
enum CityStatus { enum CityStatus {
PENDING PENDING
ACTIVE ACTIVE
@@ -646,6 +661,7 @@ model CommonSupportTicket {
ticketNo String @unique @map("ticket_no") @db.VarChar(32) ticketNo String @unique @map("ticket_no") @db.VarChar(32)
ticketType SupportTicketType @map("ticket_type") ticketType SupportTicketType @map("ticket_type")
status SupportTicketStatus @default(PENDING_REVIEW) status SupportTicketStatus @default(PENDING_REVIEW)
priority SupportTicketPriority @default(NORMAL) @map("priority")
title String @db.VarChar(128) title String @db.VarChar(128)
content String? @db.Text content String? @db.Text
rejectReason String? @map("reject_reason") @db.VarChar(512) rejectReason String? @map("reject_reason") @db.VarChar(512)
@@ -656,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)
@@ -663,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[]
@@ -848,6 +867,7 @@ model CommonPromoCode {
qrcodeResource CommonResource? @relation("PromoQrcode", fields: [qrcodeResourceId], references: [id], onDelete: SetNull) qrcodeResource CommonResource? @relation("PromoQrcode", fields: [qrcodeResourceId], references: [id], onDelete: SetNull)
attributions UserPromoAttribution[] attributions UserPromoAttribution[]
orders Order[] orders Order[]
metricEvents LogPromoEvent[]
@@index([ownerUserId]) @@index([ownerUserId])
@@index([scene, status]) @@index([scene, status])
@@ -1751,6 +1771,25 @@ model LogThirdParty {
@@map("log_third_party") @@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 { model LogUserAnalytics {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
userId BigInt? @map("user_id") @db.UnsignedBigInt userId BigInt? @map("user_id") @db.UnsignedBigInt
@@ -6,9 +6,12 @@ import { WechatPayCallbackController } from './wechat-pay.controller';
import { WechatRefundCallbackController } from './wechat-refund.controller'; import { WechatRefundCallbackController } from './wechat-refund.controller';
import { WechatMessageCallbackController } from './wechat-message.controller'; import { WechatMessageCallbackController } from './wechat-message.controller';
import { DeliveryCallbackController } from './delivery-track.controller'; import { DeliveryCallbackController } from './delivery-track.controller';
import { DeliveryCallbackService } from './delivery-callback.service';
import { CourierModule } from '../integrations/courier/courier.module';
@Module({ @Module({
imports: [IntegrationsModule, TradeModule, PrismaModule], imports: [IntegrationsModule, TradeModule, PrismaModule, CourierModule],
providers: [DeliveryCallbackService],
controllers: [ controllers: [
WechatPayCallbackController, WechatPayCallbackController,
WechatRefundCallbackController, WechatRefundCallbackController,
@@ -0,0 +1,104 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../common/prisma/prisma.module';
import { CourierService } from '../integrations/courier/courier.service';
import { XiaofeixiaProvider } from '../integrations/courier/xiaofeixia/xiaofeixia.provider';
import { logCourierCall } from '../integrations/courier/courier-log.util';
import { TradeService } from '../modules/trade/trade.service';
const XFX_PROVIDER_ALIASES = new Set(['xfx', 'xiaofeixia']);
@Injectable()
export class DeliveryCallbackService {
constructor(
private readonly prisma: PrismaService,
private readonly courier: CourierService,
private readonly xiaofeixiaProvider: XiaofeixiaProvider,
private readonly tradeService: TradeService,
) {}
async handleTrackCallback(providerKey: string, body: unknown, requestUrl: string) {
const normalized = providerKey.trim().toLowerCase();
const baseLog = {
scene: 'TRACK_CALLBACK',
requestUrl,
requestBody: (body && typeof body === 'object' ? body : { value: body }) as Record<
string,
unknown
>,
};
if (!XFX_PROVIDER_ALIASES.has(normalized) && normalized !== 'logistics') {
await logCourierCall(this.prisma, {
...baseLog,
status: 'FAILED',
errorMessage: `不支持的承运商: ${providerKey}`,
});
return this.courier.buildTrackCallbackResponse(false);
}
if (normalized === 'logistics') {
await logCourierCall(this.prisma, {
...baseLog,
status: 'SUCCESS',
errorMessage: '跨城物流回调暂未接入,已记录',
});
return this.courier.buildTrackCallbackResponse(true);
}
const payload = this.xiaofeixiaProvider.parseTrackCallback(body);
if (!payload) {
await logCourierCall(this.prisma, {
...baseLog,
status: 'FAILED',
errorMessage: '回调体解析失败',
});
return this.courier.buildTrackCallbackResponse(false);
}
const order = payload.outNumber
? await this.prisma.order.findUnique({ where: { orderNo: payload.outNumber } })
: payload.trackingNumber
? await this.prisma.order.findFirst({
where: { delivery: { trackingNo: payload.trackingNumber } },
})
: null;
if (!order) {
await logCourierCall(this.prisma, {
...baseLog,
status: 'FAILED',
errorMessage: '订单不存在',
externalNo: payload.outNumber || payload.trackingNumber,
});
return this.courier.buildTrackCallbackResponse(false);
}
const targetStatus = this.xiaofeixiaProvider.mapTrackStatus(
payload.status,
payload.statusName,
);
let applied = false;
if (targetStatus && targetStatus !== order.status) {
await this.tradeService.applyStatusTransition(
order.id,
order.status,
targetStatus,
'COURIER_TRACK_CALLBACK',
payload.trackInfo || payload.statusName,
);
applied = true;
}
const response = this.courier.buildTrackCallbackResponse(true);
await logCourierCall(this.prisma, {
...baseLog,
responseBody: response,
status: 'SUCCESS',
externalNo: order.orderNo,
ref: { refType: 'ORDER', refId: order.id },
errorMessage: applied ? undefined : '状态未变更',
});
return response;
}
}
@@ -1,71 +1,27 @@
import { Body, Controller, Post } from '@nestjs/common'; import { Body, Controller, Param, Post } from '@nestjs/common';
import { TradeService } from '../modules/trade/trade.service'; import { DeliveryCallbackService } from './delivery-callback.service';
import { CourierService } from '../integrations/courier/courier.service';
import { PrismaService } from '../common/prisma/prisma.module';
import { logCourierCall } from '../integrations/courier/courier-log.util';
@Controller('callbacks/delivery') @Controller('callbacks')
export class DeliveryCallbackController { export class DeliveryCallbackController {
constructor( constructor(private readonly deliveryCallbackService: DeliveryCallbackService) {}
private readonly tradeService: TradeService,
private readonly courier: CourierService,
private readonly prisma: PrismaService,
) {}
@Post('track') /** 小飞侠/物流路由变化回调(适配器入口) */
async track(@Body() body: { orderNo?: string; orderId?: string; status?: string }) { @Post('courier/:provider/track')
const baseLog = { trackByProvider(@Param('provider') provider: string, @Body() body: unknown) {
scene: 'TRACK_CALLBACK', return this.deliveryCallbackService.handleTrackCallback(
requestUrl: '/api/v1/callbacks/delivery/track', provider,
requestBody: body as Record<string, unknown>, body,
}; `/api/v1/callbacks/courier/${provider}/track`,
);
}
if (!body.orderId && !body.orderNo) { /** 兼容旧路径,默认按小飞侠解析 */
await logCourierCall(this.prisma, { @Post('delivery/track')
...baseLog, trackLegacy(@Body() body: unknown) {
status: 'FAILED', return this.deliveryCallbackService.handleTrackCallback(
errorMessage: '缺少 orderId / orderNo', 'xfx',
}); body,
return this.courier.buildTrackCallbackResponse(false); '/api/v1/callbacks/delivery/track',
} );
const order = body.orderId
? await this.prisma.order.findUnique({ where: { id: BigInt(body.orderId) } })
: await this.prisma.order.findUnique({ where: { orderNo: body.orderNo! } });
if (!order) {
await logCourierCall(this.prisma, {
...baseLog,
status: 'FAILED',
errorMessage: '订单不存在',
externalNo: body.orderNo,
});
return this.courier.buildTrackCallbackResponse(false);
}
const statusMap: Record<string, string> = {
SHIPPED: 'SHIPPING',
OUT_WAREHOUSE: 'OUT_WAREHOUSE',
DELIVERED: 'COMPLETED',
COMPLETED: 'COMPLETED',
};
const target = statusMap[body.status ?? ''] ?? body.status;
let applied = false;
if (target && target !== order.status) {
await this.tradeService.applyStatusTransition(order.id, order.status, target, 'DELIVERY_CALLBACK');
applied = true;
}
const response = this.courier.buildTrackCallbackResponse(true);
await logCourierCall(this.prisma, {
...baseLog,
responseBody: response,
status: 'SUCCESS',
externalNo: order.orderNo,
ref: { refType: 'ORDER', refId: order.id },
errorMessage: applied ? undefined : '状态未变更',
});
return response;
} }
} }
@@ -96,6 +96,7 @@ export const HqOperationAction = {
KNOWLEDGE_BASE_UPDATE: 'KNOWLEDGE_BASE_UPDATE', KNOWLEDGE_BASE_UPDATE: 'KNOWLEDGE_BASE_UPDATE',
KNOWLEDGE_BASE_DELETE: 'KNOWLEDGE_BASE_DELETE', KNOWLEDGE_BASE_DELETE: 'KNOWLEDGE_BASE_DELETE',
KNOWLEDGE_DOC_CREATE: 'KNOWLEDGE_DOC_CREATE', KNOWLEDGE_DOC_CREATE: 'KNOWLEDGE_DOC_CREATE',
KNOWLEDGE_DOC_UPDATE: 'KNOWLEDGE_DOC_UPDATE',
KNOWLEDGE_DOC_DELETE: 'KNOWLEDGE_DOC_DELETE', KNOWLEDGE_DOC_DELETE: 'KNOWLEDGE_DOC_DELETE',
REDEEM_PENDING_COMPLETE: 'REDEEM_PENDING_COMPLETE', REDEEM_PENDING_COMPLETE: 'REDEEM_PENDING_COMPLETE',
REDEEM_PENDING_REJECT: 'REDEEM_PENDING_REJECT', REDEEM_PENDING_REJECT: 'REDEEM_PENDING_REJECT',
@@ -218,6 +219,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
[HqOperationAction.KNOWLEDGE_BASE_UPDATE]: '更新知识库', [HqOperationAction.KNOWLEDGE_BASE_UPDATE]: '更新知识库',
[HqOperationAction.KNOWLEDGE_BASE_DELETE]: '删除知识库', [HqOperationAction.KNOWLEDGE_BASE_DELETE]: '删除知识库',
[HqOperationAction.KNOWLEDGE_DOC_CREATE]: '上传知识库文档', [HqOperationAction.KNOWLEDGE_DOC_CREATE]: '上传知识库文档',
[HqOperationAction.KNOWLEDGE_DOC_UPDATE]: '编辑知识库文档',
[HqOperationAction.KNOWLEDGE_DOC_DELETE]: '删除知识库文档', [HqOperationAction.KNOWLEDGE_DOC_DELETE]: '删除知识库文档',
[HqOperationAction.REDEEM_PENDING_COMPLETE]: '弱网待处理单-补核销', [HqOperationAction.REDEEM_PENDING_COMPLETE]: '弱网待处理单-补核销',
[HqOperationAction.REDEEM_PENDING_REJECT]: '弱网待处理单-驳回', [HqOperationAction.REDEEM_PENDING_REJECT]: '弱网待处理单-驳回',
@@ -116,6 +116,15 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
requiresRestart: false, requiresRestart: false,
description: '小程序商品首页底部 footer,建议比例 15:4;上传后需点击右上角「保存」', description: '小程序商品首页底部 footer,建议比例 15:4;上传后需点击右上角「保存」',
}, },
{
key: 'MINI_USER_MIN_VERSION',
label: '小程序最低版本',
group: G.wechat_mini,
type: 'string',
requiresRestart: false,
placeholder: '3.4.13',
description: 'semver 格式;客户端低于此版本时提示更新',
},
{ key: 'OSS_ACCESS_KEY_ID', label: 'OSS AccessKey ID', group: G.oss, type: 'password', secret: true, requiresRestart: true }, { key: 'OSS_ACCESS_KEY_ID', label: 'OSS AccessKey ID', group: G.oss, type: 'password', secret: true, requiresRestart: true },
{ key: 'OSS_ACCESS_KEY_SECRET', label: 'OSS AccessKey Secret', group: G.oss, type: 'password', secret: true, requiresRestart: true }, { key: 'OSS_ACCESS_KEY_SECRET', label: 'OSS AccessKey Secret', group: G.oss, type: 'password', secret: true, requiresRestart: true },
@@ -9,6 +9,7 @@ const CMD_SCENE: Record<string, string> = {
[XIAOFEIXIA_CMD.ESTIMATE_FREIGHT]: 'ESTIMATE_FREIGHT', [XIAOFEIXIA_CMD.ESTIMATE_FREIGHT]: 'ESTIMATE_FREIGHT',
[XIAOFEIXIA_CMD.BATCH_GET_ORDER]: 'BATCH_GET_SHIPMENT', [XIAOFEIXIA_CMD.BATCH_GET_ORDER]: 'BATCH_GET_SHIPMENT',
[XIAOFEIXIA_CMD.DELIVERY_COVERAGE]: 'CHECK_COVERAGE', [XIAOFEIXIA_CMD.DELIVERY_COVERAGE]: 'CHECK_COVERAGE',
[XIAOFEIXIA_CMD.SIGN_PHOTOS]: 'GET_SIGN_PHOTOS',
}; };
export type CourierLogRef = { export type CourierLogRef = {
@@ -29,6 +29,6 @@ import type { ICourierProvider } from './courier.types';
}, },
CourierService, CourierService,
], ],
exports: [CourierService, CourierConfigService, COURIER_PROVIDER], exports: [CourierService, CourierConfigService, COURIER_PROVIDER, XiaofeixiaProvider],
}) })
export class CourierModule {} export class CourierModule {}
@@ -12,6 +12,8 @@ import type {
ShipmentQuery, ShipmentQuery,
TrackCallbackResponse, TrackCallbackResponse,
TrackNode, TrackNode,
TrackCallbackPayload,
CourierMappedOrderStatus,
} from './courier.types'; } from './courier.types';
/** /**
@@ -45,6 +47,10 @@ export class CourierService {
return this.provider.getTrack(query, options); return this.provider.getTrack(query, options);
} }
getSignPhotos(query: ShipmentQuery, options?: CourierCallOptions): Promise<string[]> {
return this.provider.getSignPhotos(query, options);
}
checkDeliveryCoverage(toAddress: string, options?: CourierCallOptions): Promise<DeliveryCoverageResult> { checkDeliveryCoverage(toAddress: string, options?: CourierCallOptions): Promise<DeliveryCoverageResult> {
return this.provider.checkDeliveryCoverage(toAddress, options); return this.provider.checkDeliveryCoverage(toAddress, options);
} }
@@ -56,4 +62,12 @@ export class CourierService {
buildTrackCallbackResponse(success?: boolean): TrackCallbackResponse { buildTrackCallbackResponse(success?: boolean): TrackCallbackResponse {
return this.provider.buildTrackCallbackResponse(success); return this.provider.buildTrackCallbackResponse(success);
} }
parseTrackCallback(body: unknown): TrackCallbackPayload | null {
return this.provider.parseTrackCallback(body);
}
mapTrackStatus(status: string, statusName?: string): CourierMappedOrderStatus | null {
return this.provider.mapTrackStatus(status, statusName);
}
} }
@@ -109,6 +109,13 @@ export interface TrackCallbackResponse {
message: string; message: string;
} }
/** 承运商路由回调映射后的订单履约状态 */
export type CourierMappedOrderStatus =
| 'OUT_WAREHOUSE'
| 'SHIPPING'
| 'PENDING_RECEIVE'
| 'COMPLETED';
export interface ICourierProvider { export interface ICourierProvider {
readonly code: CourierProviderCode; readonly code: CourierProviderCode;
@@ -117,7 +124,10 @@ export interface ICourierProvider {
getShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise<ShipmentDetail>; getShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise<ShipmentDetail>;
batchGetShipments(query: BatchShipmentQuery, options?: CourierCallOptions): Promise<ShipmentDetail[]>; batchGetShipments(query: BatchShipmentQuery, options?: CourierCallOptions): Promise<ShipmentDetail[]>;
getTrack(query: ShipmentQuery, options?: CourierCallOptions): Promise<TrackNode[]>; getTrack(query: ShipmentQuery, options?: CourierCallOptions): Promise<TrackNode[]>;
getSignPhotos(query: ShipmentQuery, options?: CourierCallOptions): Promise<string[]>;
checkDeliveryCoverage(toAddress: string, options?: CourierCallOptions): Promise<DeliveryCoverageResult>; checkDeliveryCoverage(toAddress: string, options?: CourierCallOptions): Promise<DeliveryCoverageResult>;
estimateFreight(weight: number, options?: CourierCallOptions): Promise<FreightEstimateResult>; estimateFreight(weight: number, options?: CourierCallOptions): Promise<FreightEstimateResult>;
parseTrackCallback(body: unknown): TrackCallbackPayload | null;
mapTrackStatus(status: string, statusName?: string): CourierMappedOrderStatus | null;
buildTrackCallbackResponse(success?: boolean): TrackCallbackResponse; buildTrackCallbackResponse(success?: boolean): TrackCallbackResponse;
} }
@@ -6,6 +6,7 @@ export const XIAOFEIXIA_CMD = {
GET_ORDER: '100104', GET_ORDER: '100104',
ESTIMATE_FREIGHT: '100105', ESTIMATE_FREIGHT: '100105',
BATCH_GET_ORDER: '100106', BATCH_GET_ORDER: '100106',
SIGN_PHOTOS: '100108',
DELIVERY_COVERAGE: '100301', DELIVERY_COVERAGE: '100301',
} as const; } as const;
@@ -12,7 +12,9 @@ import type {
ShipmentDetail, ShipmentDetail,
ShipmentQuery, ShipmentQuery,
TrackCallbackResponse, TrackCallbackResponse,
TrackCallbackPayload,
TrackNode, TrackNode,
CourierMappedOrderStatus,
} from '../courier.types'; } from '../courier.types';
import { XiaofeixiaClient } from './xiaofeixia.client'; import { XiaofeixiaClient } from './xiaofeixia.client';
import { XIAOFEIXIA_CMD } from './xiaofeixia.constants'; import { XIAOFEIXIA_CMD } from './xiaofeixia.constants';
@@ -121,6 +123,59 @@ export class XiaofeixiaProvider implements ICourierProvider {
return data ?? []; return data ?? [];
} }
async getSignPhotos(query: ShipmentQuery, options?: CourierCallOptions): Promise<string[]> {
this.assertShipmentQuery(query);
const data = await this.client.request<string[]>(
XIAOFEIXIA_CMD.SIGN_PHOTOS,
{
number: query.trackingNumber,
outNumber: query.outNumber,
},
options?.xiaofeixia,
);
return (data ?? []).filter((item) => typeof item === 'string' && item.trim().length > 0);
}
parseTrackCallback(body: unknown): TrackCallbackPayload | null {
if (!body || typeof body !== 'object') return null;
const raw = body as Record<string, unknown>;
const outNumber = String(raw.outNumber ?? '').trim();
const trackingNumber = String(raw.number ?? raw.trackingNumber ?? '').trim();
const status = String(raw.status ?? '').trim();
const statusName = String(raw.statusName ?? '').trim();
const trackInfo = String(raw.trackInfo ?? '').trim();
const createTime = String(raw.createTime ?? '').trim();
if (!outNumber && !trackingNumber) return null;
if (!status && !trackInfo) return null;
return {
outNumber,
trackingNumber,
status,
statusName,
trackInfo,
createTime,
};
}
mapTrackStatus(status: string, statusName?: string): CourierMappedOrderStatus | null {
const name = (statusName || '').trim();
if (status === '5' || /签收/.test(name)) {
return 'COMPLETED';
}
switch (String(status).trim()) {
case '1':
return 'OUT_WAREHOUSE';
case '2':
case '3':
case '4':
return 'SHIPPING';
case '7':
return null;
default:
return null;
}
}
async checkDeliveryCoverage(toAddress: string, options?: CourierCallOptions): Promise<DeliveryCoverageResult> { async checkDeliveryCoverage(toAddress: string, options?: CourierCallOptions): Promise<DeliveryCoverageResult> {
const data = await this.client.request<XiaofeixiaDeliveryCoverageData>( const data = await this.client.request<XiaofeixiaDeliveryCoverageData>(
XIAOFEIXIA_CMD.DELIVERY_COVERAGE, XIAOFEIXIA_CMD.DELIVERY_COVERAGE,
@@ -5,7 +5,7 @@ import { AlertService } from '../common/alert/alert.service';
/** /**
* 财务对账单定时任务(Asia/Shanghai * 财务对账单定时任务(Asia/Shanghai
* - 每日 08:00:酒厂日账单 + 门店日账单(统计昨日 00:00~今日 00:00 * - 每日 08:00:酒厂日账单(T+3:3 天前已完成订单)+ 门店日账单(昨日核销
* - 每月 1 日 08:00:合伙人上一自然月账单 + 物流承运商上一自然月对账 * - 每月 1 日 08:00:合伙人上一自然月账单 + 物流承运商上一自然月对账
* - 工作日 18:05:门店提现 T+0 审完预警(FIN-003 * - 工作日 18:05:门店提现 T+0 审完预警(FIN-003
*/ */
@@ -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 { AnalyticsService } from './analytics.service';
import { PromoCodeService } from '../promo/promo-code.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 { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard'; import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
@@ -66,8 +68,13 @@ export class PromoController {
@Post('touch') @Post('touch')
@UseGuards(OptionalJwtAuthGuard) @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 userId = user?.actorType === ActorType.USER ? user.actorId : undefined;
const clientIp = extractClientIp(req) ?? undefined;
const result = await this.promoCodeService.touch( const result = await this.promoCodeService.touch(
{ {
promoCode: dto.promoCode, promoCode: dto.promoCode,
@@ -76,6 +83,7 @@ export class PromoController {
countScan: dto.countScan, countScan: dto.countScan,
}, },
userId, userId,
{ clientIp, sessionId: dto.sessionId },
); );
void this.analyticsService.trackBatchOptional( void this.analyticsService.trackBatchOptional(
@@ -11,6 +11,7 @@ export class ClientConfigController {
const cfg = this.systemConfig.getAppConfig(); const cfg = this.systemConfig.getAppConfig();
const env = this.systemConfig.getMergedEnv(); const env = this.systemConfig.getMergedEnv();
const footer = (env.MINI_HOME_FOOTER_URL ?? '').trim(); const footer = (env.MINI_HOME_FOOTER_URL ?? '').trim();
const minClientVersion = (env.MINI_USER_MIN_VERSION ?? '').trim() || null;
return { return {
mockPay: cfg.mockPay, mockPay: cfg.mockPay,
wechatPayEnabled: cfg.wechatPayEnabled, wechatPayEnabled: cfg.wechatPayEnabled,
@@ -19,6 +20,7 @@ export class ClientConfigController {
wxAuthorize: cfg.wxAuthorize, wxAuthorize: cfg.wxAuthorize,
/** 可选暴露;选点已改为服务端 /common/lbs,前端可不依赖此字段 */ /** 可选暴露;选点已改为服务端 /common/lbs,前端可不依赖此字段 */
tencentLbsKey: cfg.tencentLbsKey || undefined, tencentLbsKey: cfg.tencentLbsKey || undefined,
minClientVersion,
miniHome: { miniHome: {
banners: parseMiniHomeBanners(env.MINI_HOME_BANNERS), banners: parseMiniHomeBanners(env.MINI_HOME_BANNERS),
footerUrl: footer || null, footerUrl: footer || null,
@@ -5,6 +5,7 @@ import { AlertService } from '../../common/alert/alert.service';
import type { AlertLevel } from '../../common/alert/alert.constants'; import type { AlertLevel } from '../../common/alert/alert.constants';
import type { AuthUser } from '../../common/guards/jwt-auth.guard'; import type { AuthUser } from '../../common/guards/jwt-auth.guard';
import type { ReportClientErrorDto } from './dto/client-error.dto'; import type { ReportClientErrorDto } from './dto/client-error.dto';
import { SupportTicketService } from './support-ticket.service';
const WECOM_LEVELS = new Set(['fatal', 'error']); const WECOM_LEVELS = new Set(['fatal', 'error']);
@@ -15,6 +16,7 @@ export class ClientErrorService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly alert: AlertService, private readonly alert: AlertService,
private readonly supportTicket: SupportTicketService,
) {} ) {}
async report(dto: ReportClientErrorDto, user?: AuthUser, headerClientApp?: string) { async report(dto: ReportClientErrorDto, user?: AuthUser, headerClientApp?: string) {
@@ -107,6 +109,25 @@ export class ClientErrorService {
); );
} }
if (dto.category === 'validation_error') {
const apiPath =
dto.extra && typeof dto.extra.url === 'string' ? dto.extra.url.slice(0, 256) : undefined;
void this.supportTicket
.createFromClientValidation({
clientApp,
message,
pagePath,
apiPath,
actorLabel:
user?.actorId != null ? `${user.actorType}:${String(user.actorId)}` : undefined,
})
.catch((e) => {
this.logger.warn(
`auto support ticket failed: ${e instanceof Error ? e.message : String(e)}`,
);
});
}
return { ok: true }; return { ok: true };
} }
} }
@@ -9,6 +9,7 @@ export const CLIENT_ERROR_CATEGORIES = [
'js_error', 'js_error',
'unhandled_rejection', 'unhandled_rejection',
'api_error', 'api_error',
'validation_error',
'network', 'network',
'render', 'render',
'bridge', 'bridge',
@@ -11,6 +11,10 @@ export class SupportTicketListQueryDto {
@IsIn(['PENDING_REVIEW', 'REJECTED', 'DEVELOPING', 'TESTING', 'PASSED']) @IsIn(['PENDING_REVIEW', 'REJECTED', 'DEVELOPING', 'TESTING', 'PASSED'])
status?: string; status?: string;
@IsOptional()
@IsIn(['LOW', 'NORMAL', 'HIGH', 'URGENT'])
priority?: string;
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
@IsInt() @IsInt()
@@ -46,6 +50,10 @@ export class CreateSupportTicketDto {
@IsArray() @IsArray()
@IsString({ each: true }) @IsString({ each: true })
attachmentUrls?: string[]; attachmentUrls?: string[];
@IsOptional()
@IsIn(['LOW', 'NORMAL', 'HIGH', 'URGENT'])
priority?: string;
} }
export class UpdateSupportTicketDto { export class UpdateSupportTicketDto {
@@ -72,6 +80,10 @@ export class UpdateSupportTicketDto {
@IsArray() @IsArray()
@IsString({ each: true }) @IsString({ each: true })
attachmentUrls?: string[]; attachmentUrls?: string[];
@IsOptional()
@IsIn(['LOW', 'NORMAL', 'HIGH', 'URGENT'])
priority?: string;
} }
export class BatchUpdateSupportTicketStatusDto { export class BatchUpdateSupportTicketStatusDto {
@@ -95,6 +107,32 @@ export class BatchUpdateSupportTicketStatusDto {
note?: string; note?: string;
} }
export class BatchCreateSupportTicketTasksDto {
@IsArray()
@ArrayMinSize(1)
@IsString({ each: true })
ticketIds!: string[];
}
export class BatchPublishSupportTicketsDto {
@IsArray()
@ArrayMinSize(1)
@IsString({ each: true })
ticketIds!: string[];
@IsString()
@IsNotEmpty()
versionId!: string;
@IsOptional()
@IsBoolean()
dispatchToWecom?: boolean;
@IsOptional()
@IsString()
dispatchSupplement?: string;
}
export class RejectSupportTicketDto { export class RejectSupportTicketDto {
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@@ -3,9 +3,10 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import type { SupportTicketStatus, SupportTicketType } from '@prisma/client'; import type { SupportTicketStatus, SupportTicketType, SupportTicketPriority } from '@prisma/client';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import type { CreateDevPlanTaskFromTicketInput } from '@dukang/shared-types'; import type { CreateDevPlanTaskFromTicketInput } from '@dukang/shared-types';
import { mapSupportTicketTypeToDevPlanTask } from '@dukang/shared-types';
import { validateSupportTicketStatusTransition } from '@dukang/domain'; import { validateSupportTicketStatusTransition } from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module'; import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { serializeBigInt } from '../../common/decorators/current-user.decorator';
@@ -37,8 +38,17 @@ function generateSupportTicketNo() {
return `ST${Date.now()}${Math.floor(Math.random() * 900 + 100)}`; return `ST${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
} }
const AUTO_VALIDATION_TICKET_APPS = new Set([
'USER_H5',
'USER_MINI',
'SHOP_H5',
'PARTNER_H5',
]);
@Injectable() @Injectable()
export class SupportTicketService { export class SupportTicketService {
private systemCreatorCache: { id: bigint; name: string } | null = null;
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly alert: AlertService, private readonly alert: AlertService,
@@ -58,6 +68,7 @@ export class SupportTicketService {
title: dto.title.trim(), title: dto.title.trim(),
content: dto.content?.trim() || null, content: dto.content?.trim() || null,
remark: dto.remark?.trim() || null, remark: dto.remark?.trim() || null,
priority: (dto.priority as SupportTicketPriority | undefined) ?? 'NORMAL',
attachmentUrls: dto.attachmentUrls?.length attachmentUrls: dto.attachmentUrls?.length
? (dto.attachmentUrls.map((u) => u.trim()).filter(Boolean) as unknown as Prisma.InputJsonValue) ? (dto.attachmentUrls.map((u) => u.trim()).filter(Boolean) as unknown as Prisma.InputJsonValue)
: undefined, : undefined,
@@ -94,6 +105,66 @@ export class SupportTicketService {
return serializeBigInt(mapSupportTicketRow(ticket)); return serializeBigInt(mapSupportTicketRow(ticket));
} }
/** 客户端 400 验证错误自动建单(1 小时内同标题去重) */
async createFromClientValidation(input: {
clientApp: string;
message: string;
pagePath?: string;
apiPath?: string;
actorLabel?: string;
}) {
if (!AUTO_VALIDATION_TICKET_APPS.has(input.clientApp)) {
return { skipped: true as const, reason: 'unsupported_app' as const };
}
const title = `[客户端验证] ${input.clientApp}${input.pagePath ? ` · ${input.pagePath}` : ''} · ${input.message.slice(0, 60)}`;
const oneHourAgo = new Date(Date.now() - 3600_000);
const existing = await this.prisma.commonSupportTicket.findFirst({
where: { title, createdAt: { gte: oneHourAgo } },
select: { id: true },
});
if (existing) {
return { skipped: true as const, ticketId: existing.id.toString() };
}
const creator = await this.resolveSystemCreator();
const content = [
`端:${input.clientApp}`,
input.pagePath ? `页面:${input.pagePath}` : null,
input.apiPath ? `接口:${input.apiPath}` : null,
input.actorLabel ? `用户:${input.actorLabel}` : null,
'',
input.message,
]
.filter(Boolean)
.join('\n');
const ticket = await this.create(
{
ticketType: 'BUG',
title,
content,
remark: '客户端验证错误自动上报',
},
creator,
);
return { skipped: false as const, ticketId: String(ticket.id) };
}
private async resolveSystemCreator() {
if (this.systemCreatorCache) return this.systemCreatorCache;
const account = await this.prisma.hqAccount.findFirst({
where: { adminRole: 'SUPER_ADMIN', status: 'ACTIVE' },
orderBy: { id: 'asc' },
select: { id: true },
});
if (!account) {
throw new BadRequestException('未找到系统管理员账号,无法自动创建工单');
}
this.systemCreatorCache = { id: account.id, name: '系统自动' };
return this.systemCreatorCache;
}
async update(id: bigint, dto: UpdateSupportTicketDto) { async update(id: bigint, dto: UpdateSupportTicketDto) {
const ticket = await this.getOrThrow(id); const ticket = await this.getOrThrow(id);
if (ticket.status !== 'PENDING_REVIEW') { if (ticket.status !== 'PENDING_REVIEW') {
@@ -108,6 +179,7 @@ export class SupportTicketService {
const urls = dto.attachmentUrls.map((u) => u.trim()).filter(Boolean); const urls = dto.attachmentUrls.map((u) => u.trim()).filter(Boolean);
data.attachmentUrls = urls.length ? (urls as unknown as Prisma.InputJsonValue) : Prisma.JsonNull; data.attachmentUrls = urls.length ? (urls as unknown as Prisma.InputJsonValue) : Prisma.JsonNull;
} }
if (dto.priority != null) data.priority = dto.priority as SupportTicketPriority;
const updated = await this.prisma.commonSupportTicket.update({ where: { id }, data }); const updated = await this.prisma.commonSupportTicket.update({ where: { id }, data });
return serializeBigInt(mapSupportTicketRow(updated)); return serializeBigInt(mapSupportTicketRow(updated));
} }
@@ -177,6 +249,9 @@ export class SupportTicketService {
if (query.status) { if (query.status) {
where.status = query.status as SupportTicketStatus; where.status = query.status as SupportTicketStatus;
} }
if (query.priority) {
where.priority = query.priority as SupportTicketPriority;
}
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
this.prisma.commonSupportTicket.findMany({ this.prisma.commonSupportTicket.findMany({
@@ -360,6 +435,134 @@ export class SupportTicketService {
return { items: results }; return { items: results };
} }
/** 批量一键创建开发任务(每工单 1 条,内容取自标题/说明) */
async batchCreateTasks(
ticketIds: bigint[],
operator: { id: bigint; name: string },
) {
const results: Array<{
ticketId: string;
ok: boolean;
message?: string;
taskIds?: string[];
}> = [];
for (const id of ticketIds) {
try {
const ticket = await this.getOrThrow(id);
const linkedMap = await this.devPlan.listLinkedTasksByTicketIds([id]);
const existing = linkedMap.get(String(id)) ?? [];
if (existing.length > 0) {
results.push({ ticketId: String(id), ok: false, message: '已有开发任务,已跳过' });
continue;
}
const summary = [ticket.title, ticket.content].filter(Boolean).join('\n').slice(0, 500);
const createdTasks = await this.devPlan.createTasksFromTicket(
id,
[
{
content: summary || ticket.title,
type: mapSupportTicketTypeToDevPlanTask(ticket.ticketType as 'BUG' | 'SUGGESTION' | 'OTHER'),
},
],
operator.id,
);
if (ticket.status === 'PENDING_REVIEW') {
await this.prisma.commonSupportTicket.update({
where: { id },
data: {
status: 'DEVELOPING',
reviewerId: operator.id,
reviewerName: operator.name,
reviewedAt: new Date(),
},
});
}
results.push({
ticketId: String(id),
ok: true,
taskIds: createdTasks.map((t) => t.id),
});
} catch (err) {
results.push({
ticketId: String(id),
ok: false,
message: err instanceof Error ? err.message : '创建失败',
});
}
}
const successCount = results.filter((r) => r.ok).length;
return { results, successCount, failCount: results.length - successCount };
}
/** 批量一键发布:关联开发版本,可选企微派发 */
async batchPublish(
ticketIds: bigint[],
input: {
versionId: string;
dispatchToWecom?: boolean;
dispatchSupplement?: string;
},
operator: { id: bigint; name: string },
) {
const perTicket: Array<{ ticketId: string; ok: boolean; message?: string }> = [];
const taskIdSet = new Set<string>();
for (const id of ticketIds) {
const linkedMap = await this.devPlan.listLinkedTasksByTicketIds([id]);
const linked = linkedMap.get(String(id)) ?? [];
if (!linked.length) {
perTicket.push({ ticketId: String(id), ok: false, message: '无关联开发任务' });
continue;
}
linked.forEach((t) => taskIdSet.add(t.id));
perTicket.push({ ticketId: String(id), ok: true });
}
const taskIds = [...taskIdSet];
if (!taskIds.length) {
return {
results: perTicket,
successCount: 0,
failCount: perTicket.length,
linkedTaskCount: 0,
};
}
await this.devPlan.batchUpdateTasks({ taskIds, versionId: input.versionId });
if (input.dispatchToWecom) {
try {
await this.devPlan.dispatchTasks(
{ taskIds, supplement: input.dispatchSupplement },
operator.id,
);
} catch (err) {
this.alert.notify({
level: 'P2',
category: 'ops',
title: '批量发布企微派发失败',
detail: err instanceof Error ? err.message : String(err),
dedupeKey: `support_batch_publish_dispatch_fail|${Date.now()}`,
dedupeTtlSec: 120,
});
}
}
const successCount = perTicket.filter((r) => r.ok).length;
return {
results: perTicket,
successCount,
failCount: perTicket.length - successCount,
linkedTaskCount: taskIds.length,
versionId: input.versionId,
};
}
/** 开发完成 → 测试 */ /** 开发完成 → 测试 */
async startTesting(id: bigint, dto?: SupportTicketRemarkDto) { async startTesting(id: bigint, dto?: SupportTicketRemarkDto) {
const ticket = await this.getOrThrow(id); const ticket = await this.getOrThrow(id);
@@ -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;
@@ -9,8 +9,10 @@ import {
} from '@dukang/domain'; } from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module'; import { PrismaService } from '../../common/prisma/prisma.module';
import { CourierService } from '../../integrations/courier/courier.service'; import { CourierService } from '../../integrations/courier/courier.service';
import { CourierPayMode } from '../../integrations/courier/courier.types'; import { CourierPayMode, type TrackNode } from '../../integrations/courier/courier.types';
import type { XiaofeixiaConfig } from '../../integrations/courier/courier.config'; import type { XiaofeixiaConfig } from '../../integrations/courier/courier.config';
import { OSS_PROVIDER } from '../../integrations/integrations.constants';
import type { IOssProvider } from '../../integrations/oss/oss.interface';
import { TradeService } from '../trade/trade.service'; import { TradeService } from '../trade/trade.service';
import { FulfillmentProviderService } from './fulfillment-provider.service'; import { FulfillmentProviderService } from './fulfillment-provider.service';
@@ -32,6 +34,7 @@ export class FulfillmentService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly courier: CourierService, private readonly courier: CourierService,
private readonly fulfillmentProviderService: FulfillmentProviderService, private readonly fulfillmentProviderService: FulfillmentProviderService,
@Inject(OSS_PROVIDER) private readonly oss: IOssProvider,
@Inject(forwardRef(() => TradeService)) @Inject(forwardRef(() => TradeService))
private readonly tradeService: TradeService, private readonly tradeService: TradeService,
) {} ) {}
@@ -246,13 +249,30 @@ export class FulfillmentService {
async getOrderTrack(orderId: bigint) { async getOrderTrack(orderId: bigint) {
const order = await this.prisma.order.findUnique({ const order = await this.prisma.order.findUnique({
where: { id: orderId }, where: { id: orderId },
include: { delivery: true }, include: {
delivery: {
include: { signPhotoResource: true },
},
},
}); });
const base = {
nodes: [] as TrackNode[],
signPhotoUrls: [] as string[],
estimatedArrival: null as { arriveTime: string; siteName?: string } | null,
manualQueryUrl: order?.delivery?.manualQueryUrl ?? null,
provider: order?.delivery?.provider,
trackingNo: order?.delivery?.trackingNo ?? null,
logisticsCompany: order?.delivery?.logisticsCompany ?? null,
};
if (!order?.delivery) { if (!order?.delivery) {
return { nodes: [], manualQueryUrl: null }; return base;
} }
if (order.delivery.provider === 'XFX' && (order.delivery.trackingNo || order.orderNo)) { const isXfx =
order.delivery.provider === 'XFX' || isXfxProviderCode(String(order.delivery.provider || ''));
const canQueryCourier = isXfx && (order.delivery.trackingNo || order.orderNo);
if (canQueryCourier) {
try { try {
const options = order.delivery.fulfillmentProviderId const options = order.delivery.fulfillmentProviderId
? { ? {
@@ -261,27 +281,44 @@ export class FulfillmentService {
), ),
} }
: undefined; : undefined;
const nodes = await this.courier.getTrack( const shipmentQuery = {
{ trackingNumber: order.delivery.trackingNo ?? undefined,
trackingNumber: order.delivery.trackingNo ?? undefined, outNumber: order.orderNo,
outNumber: order.orderNo,
},
options,
);
return {
nodes,
manualQueryUrl: order.delivery.manualQueryUrl,
provider: order.delivery.provider,
trackingNo: order.delivery.trackingNo,
logisticsCompany: order.delivery.logisticsCompany,
}; };
const [nodes, signPhotoDataUris] = await Promise.all([
this.courier.getTrack(shipmentQuery, options).catch(() => [] as TrackNode[]),
this.courier.getSignPhotos(shipmentQuery, options).catch(() => [] as string[]),
]);
base.nodes = this.sortTrackNodesOldestFirst(nodes);
base.signPhotoUrls = await this.resolveSignPhotoUrls(order, signPhotoDataUris);
if (this.shouldFetchEstimatedArrival(order.status, base.nodes)) {
const toAddress = this.buildReceiverAddress(order);
if (toAddress) {
try {
const coverage = await this.courier.checkDeliveryCoverage(toAddress, options);
base.estimatedArrival = {
arriveTime: coverage.arriveTime,
siteName: coverage.siteName,
};
} catch {
// 预估送达失败不阻断
}
}
}
} catch { } catch {
// fall through // fall through
} }
} }
if (base.signPhotoUrls.length === 0 && order.delivery.signPhotoResource?.url) {
base.signPhotoUrls = [order.delivery.signPhotoResource.url];
}
return { return {
nodes: [], ...base,
manualQueryUrl: order.delivery.manualQueryUrl, manualQueryUrl: order.delivery.manualQueryUrl,
provider: order.delivery.provider, provider: order.delivery.provider,
trackingNo: order.delivery.trackingNo, trackingNo: order.delivery.trackingNo,
@@ -289,6 +326,119 @@ export class FulfillmentService {
}; };
} }
private sortTrackNodesOldestFirst(nodes: TrackNode[]): TrackNode[] {
return [...nodes].sort((a, b) => {
const ta = new Date(a.createTime).getTime();
const tb = new Date(b.createTime).getTime();
if (Number.isNaN(ta) && Number.isNaN(tb)) return 0;
if (Number.isNaN(ta)) return 1;
if (Number.isNaN(tb)) return -1;
return ta - tb;
});
}
private buildReceiverAddress(order: {
receiverProvince?: string | null;
receiverCity?: string | null;
receiverDistrict?: string | null;
receiverAddress?: string | null;
}) {
const region = [order.receiverProvince, order.receiverCity, order.receiverDistrict]
.filter(Boolean)
.join('');
const detail = (order.receiverAddress || '').trim();
if (!region && !detail) return '';
if (region && detail.startsWith(region)) return detail;
return `${region}${detail}`;
}
private shouldFetchEstimatedArrival(status: string, nodes: TrackNode[]) {
if (!['OUT_WAREHOUSE', 'SHIPPING', 'SHIPPED'].includes(status)) return false;
if (nodes.length === 0) return true;
return !nodes.some(
(node) =>
node.statusName?.includes('签收') ||
node.trackInfo?.includes('签收') ||
node.statusName?.includes('派件'),
);
}
private async resolveSignPhotoUrls(
order: {
id: bigint;
delivery: {
id: bigint;
signPhotoResourceId: bigint | null;
signPhotoResource: { url: string } | null;
} | null;
},
dataUris: string[],
): Promise<string[]> {
if (!order.delivery || dataUris.length === 0) {
return order.delivery?.signPhotoResource?.url ? [order.delivery.signPhotoResource.url] : [];
}
const urls: string[] = [];
let firstResourceId: bigint | null = order.delivery.signPhotoResourceId;
for (let i = 0; i < dataUris.length; i += 1) {
const parsed = this.parseDataUri(dataUris[i]);
if (!parsed) continue;
const result = await this.oss.putObject({
bizType: 'SIGN_PHOTO',
mediaType: 'IMAGE',
fileName: `sign-${order.id}-${i + 1}.${parsed.ext}`,
buffer: parsed.buffer,
mimeType: parsed.mimeType,
});
urls.push(result.url);
if (!firstResourceId) {
const resource = await this.prisma.commonResource.create({
data: {
ownerType: 'ORDER',
ownerId: order.id,
bizType: 'SIGN_PHOTO',
mediaType: 'IMAGE',
ossBucket: result.bucket,
ossKey: result.ossKey,
url: result.url,
fileName: `sign-${order.id}-${i + 1}.${parsed.ext}`,
fileSize: BigInt(parsed.buffer.length),
mimeType: parsed.mimeType,
status: 'ACTIVE',
},
});
firstResourceId = resource.id;
}
}
if (firstResourceId && firstResourceId !== order.delivery.signPhotoResourceId) {
await this.prisma.orderDelivery.update({
where: { id: order.delivery.id },
data: { signPhotoResourceId: firstResourceId },
});
}
return urls;
}
private parseDataUri(dataUri: string): { buffer: Buffer; mimeType: string; ext: string } | null {
const trimmed = dataUri.trim();
const match = /^data:([^;]+);base64,(.+)$/i.exec(trimmed);
const base64 = match ? match[2] : trimmed;
const mimeType = match?.[1] || 'image/png';
try {
const buffer = Buffer.from(base64, 'base64');
if (!buffer.length) return null;
const ext = mimeType.includes('jpeg') || mimeType.includes('jpg') ? 'jpg' : 'png';
return { buffer, mimeType, ext };
} catch {
return null;
}
}
private async applyManualShip( private async applyManualShip(
order: Order & { delivery: { trackingNo: string | null } | null }, order: Order & { delivery: { trackingNo: string | null } | null },
input: ManualShipInput & { operator: string; provider?: 'MANUAL' | 'LOGISTICS' }, input: ManualShipInput & { operator: string; provider?: 'MANUAL' | 'LOGISTICS' },
@@ -1692,6 +1692,10 @@ export class AuthService {
throw new BadRequestException('首次登录请使用手机验证码,登录后将自动关联微信'); throw new BadRequestException('首次登录请使用手机验证码,登录后将自动关联微信');
} }
if (account.status !== 'ACTIVE') {
throw new BadRequestException('合伙人账号已停用');
}
account = await this.prisma.partnerAccount.update({ account = await this.prisma.partnerAccount.update({
where: { id: account.id }, where: { id: account.id },
data: { data: {
@@ -13,6 +13,7 @@ import type {
CreateKnowledgeBaseRequest, CreateKnowledgeBaseRequest,
CreateKnowledgeDocumentRequest, CreateKnowledgeDocumentRequest,
UpdateKnowledgeBaseRequest, UpdateKnowledgeBaseRequest,
UpdateKnowledgeDocumentRequest,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { import {
@@ -119,6 +120,33 @@ export class AdminKnowledgeBasesController {
return this.service.addDocument(actor, BigInt(id), body); return this.service.addDocument(actor, BigInt(id), body);
} }
@Get(':id/documents/:docId')
async getDocument(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Param('docId') docId: string,
) {
const actor = await this.service.resolveActor(user.actorId);
return this.service.getDocument(actor, BigInt(id), BigInt(docId));
}
@Put(':id/documents/:docId')
@HqOperation({
action: HqOperationAction.KNOWLEDGE_DOC_UPDATE,
refType: 'KNOWLEDGE_DOCUMENT',
refIdField: 'docId',
includeBody: true,
})
async updateDocument(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Param('docId') docId: string,
@Body() body: UpdateKnowledgeDocumentRequest,
) {
const actor = await this.service.resolveActor(user.actorId);
return this.service.updateDocument(actor, BigInt(id), BigInt(docId), body);
}
@Delete(':id/documents/:docId') @Delete(':id/documents/:docId')
@HqOperation({ @HqOperation({
action: HqOperationAction.KNOWLEDGE_DOC_DELETE, action: HqOperationAction.KNOWLEDGE_DOC_DELETE,
@@ -9,8 +9,10 @@ import type {
CreateKnowledgeDocumentRequest, CreateKnowledgeDocumentRequest,
KnowledgeBaseDto, KnowledgeBaseDto,
KnowledgeBaseOptionDto, KnowledgeBaseOptionDto,
KnowledgeDocumentDetailDto,
KnowledgeDocumentDto, KnowledgeDocumentDto,
UpdateKnowledgeBaseRequest, UpdateKnowledgeBaseRequest,
UpdateKnowledgeDocumentRequest,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module'; import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { serializeBigInt } from '../../common/decorators/current-user.decorator';
@@ -228,6 +230,113 @@ export class AdminKnowledgeBasesService {
return this.toDocDto(row); return this.toDocDto(row);
} }
async getDocument(actor: ActorCtx, kbId: bigint, docId: bigint): Promise<KnowledgeDocumentDetailDto> {
await this.requireKb(actor, kbId);
const doc = await this.prisma.knowledgeDocument.findFirst({
where: { id: docId, knowledgeBaseId: kbId },
});
if (!doc) throw new NotFoundException('文档不存在');
return {
...this.toDocDto(doc),
contentText: doc.contentText,
};
}
async updateDocument(
actor: ActorCtx,
kbId: bigint,
docId: bigint,
dto: UpdateKnowledgeDocumentRequest,
) {
const kb = await this.requireKb(actor, kbId);
this.requireWrite(actor, kb);
const doc = await this.prisma.knowledgeDocument.findFirst({
where: { id: docId, knowledgeBaseId: kbId },
});
if (!doc) throw new NotFoundException('文档不存在');
const data: {
title?: string;
fileName?: string | null;
fileUrl?: string | null;
mimeType?: string | null;
sizeBytes?: number | null;
contentText?: string | null;
status?: 'READY' | 'EMPTY' | 'FAILED';
errorMessage?: string | null;
} = {};
if (dto.title !== undefined) {
const title = dto.title.trim();
if (!title) throw new BadRequestException('请填写标题');
data.title = title;
}
const hasContentInput =
dto.contentText !== undefined ||
dto.fileUrl !== undefined ||
dto.fileName !== undefined ||
dto.mimeType !== undefined;
if (hasContentInput) {
let contentText = dto.contentText !== undefined ? dto.contentText?.trim() || '' : doc.contentText?.trim() || '';
let status: 'READY' | 'EMPTY' | 'FAILED' = doc.status as 'READY' | 'EMPTY' | 'FAILED';
let errorMessage: string | null = doc.errorMessage;
if (dto.contentText !== undefined) {
if (contentText) {
status = 'READY';
errorMessage = null;
} else if (!dto.fileUrl?.trim() && !doc.fileUrl) {
status = 'EMPTY';
errorMessage = null;
}
data.contentText = contentText || null;
}
if (dto.fileUrl !== undefined) data.fileUrl = dto.fileUrl?.trim() || null;
if (dto.fileName !== undefined) data.fileName = dto.fileName?.trim() || null;
if (dto.mimeType !== undefined) data.mimeType = dto.mimeType?.trim() || null;
if (dto.sizeBytes !== undefined) data.sizeBytes = dto.sizeBytes ?? null;
const fileUrl = dto.fileUrl?.trim() || doc.fileUrl;
const fileName = dto.fileName?.trim() || doc.fileName || '';
const mimeType = dto.mimeType?.trim() || doc.mimeType;
if (dto.fileUrl?.trim() && dto.contentText === undefined) {
if (TEXT_EXT.test(fileName) || isLikelyTextMime(mimeType)) {
try {
contentText = await fetchText(dto.fileUrl.trim());
data.contentText = contentText || null;
status = contentText.trim() ? 'READY' : 'EMPTY';
errorMessage = contentText.trim() ? null : '文件内容为空';
} catch (e) {
status = 'FAILED';
errorMessage = e instanceof Error ? e.message : String(e);
}
} else {
status = 'EMPTY';
errorMessage = '非文本文件未抽取正文,请粘贴文本或上传 .txt/.md';
}
} else if (dto.contentText !== undefined && contentText) {
status = 'READY';
errorMessage = null;
} else if (dto.contentText !== undefined && !contentText && !fileUrl) {
status = 'EMPTY';
errorMessage = null;
}
data.status = status;
data.errorMessage = errorMessage;
}
const updated = await this.prisma.knowledgeDocument.update({
where: { id: docId },
data,
});
return this.toDocDto(updated);
}
async removeDocument(actor: ActorCtx, kbId: bigint, docId: bigint) { async removeDocument(actor: ActorCtx, kbId: bigint, docId: bigint) {
const kb = await this.requireKb(actor, kbId); const kb = await this.requireKb(actor, kbId);
this.requireWrite(actor, kb); this.requireWrite(actor, kb);
@@ -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 } },
@@ -25,6 +25,8 @@ import {
SupportTicketRemarkDto, SupportTicketRemarkDto,
UpdateSupportTicketDto, UpdateSupportTicketDto,
BatchUpdateSupportTicketStatusDto, BatchUpdateSupportTicketStatusDto,
BatchCreateSupportTicketTasksDto,
BatchPublishSupportTicketsDto,
} from '../common/dto/support-ticket.dto'; } from '../common/dto/support-ticket.dto';
import { import {
BatchReviewConfirmDto, BatchReviewConfirmDto,
@@ -109,6 +111,26 @@ export class AdminSupportTicketsController {
); );
} }
@Post('batch-create-tasks')
@UseGuards(SuperAdminGuard)
async batchCreateTasks(
@CurrentUser() user: AuthUser,
@Body() body: BatchCreateSupportTicketTasksDto,
) {
const account = await this.resolveHqAccount(user);
return this.service.batchCreateTasks(body.ticketIds.map(BigInt), account);
}
@Post('batch-publish')
@UseGuards(SuperAdminGuard)
async batchPublish(
@CurrentUser() user: AuthUser,
@Body() body: BatchPublishSupportTicketsDto,
) {
const account = await this.resolveHqAccount(user);
return this.service.batchPublish(body.ticketIds.map(BigInt), body, account);
}
@Get(':id') @Get(':id')
detail(@Param('id') id: string) { detail(@Param('id') id: string) {
return this.service.detail(BigInt(id)); return this.service.detail(BigInt(id));
@@ -6,6 +6,8 @@ import { PromoCodeService } from './promo-code.service';
import { import {
CreatePromoCodeDto, CreatePromoCodeDto,
PromoCodeListQueryDto, PromoCodeListQueryDto,
PromoMetricEventsQueryDto,
PromoMetricTimelineQueryDto,
UpdatePromoCodeDto, UpdatePromoCodeDto,
UpdatePromoCodeStatusDto, UpdatePromoCodeStatusDto,
} from './dto/promo-code.dto'; } from './dto/promo-code.dto';
@@ -39,6 +41,16 @@ export class AdminPromoCodeController {
return this.service.stats(BigInt(id)); 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') @Get(':id/qrcode')
qrcode(@Param('id') id: string) { qrcode(@Param('id') id: string) {
return this.service.getQrcodeUrl(BigInt(id)); return this.service.getQrcodeUrl(BigInt(id));
@@ -87,3 +87,37 @@ export class UpdatePromoCodeStatusDto {
@IsIn(['ACTIVE', 'DISABLED']) @IsIn(['ACTIVE', 'DISABLED'])
status: PromoCodeStatus; 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 { import {
PROMO_CODE_SCENE_LABELS, PROMO_CODE_SCENE_LABELS,
PromoCodeScene, PromoCodeScene,
PromoMetricEventType,
buildPromoLandingUrl, buildPromoLandingUrl,
loadAppConfig, loadAppConfig,
} from '@dukang/shared-types'; } 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 { OSS_PROVIDER, WECHAT_PROVIDER } from '../../integrations/integrations.constants';
import type { IOssProvider } from '../../integrations/oss/oss.interface'; import type { IOssProvider } from '../../integrations/oss/oss.interface';
import type { IWechatProvider } from '../../integrations/wechat/wechat.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 { import type {
CreatePromoCodeDto, CreatePromoCodeDto,
PromoCodeListQueryDto, PromoCodeListQueryDto,
PromoMetricEventsQueryDto,
PromoMetricTimelineQueryDto,
UpdatePromoCodeDto, UpdatePromoCodeDto,
} from './dto/promo-code.dto'; } from './dto/promo-code.dto';
type PromoTouchMeta = {
clientIp?: string;
sessionId?: string;
};
type PromoRow = { type PromoRow = {
id: bigint; id: bigint;
code: string; code: string;
@@ -69,6 +88,7 @@ function maskPhone(phone: string | null | undefined) {
export class PromoCodeService { export class PromoCodeService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly promoMetricLog: PromoMetricLogService,
@Inject(OSS_PROVIDER) private readonly oss: IOssProvider, @Inject(OSS_PROVIDER) private readonly oss: IOssProvider,
@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider, @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({ const promo = await this.prisma.commonPromoCode.findUnique({
where: { id: promoId }, where: { id: promoId },
select: { id: true, name: true, status: true }, select: { id: true, name: true, status: true },
@@ -108,9 +132,13 @@ export class PromoCodeService {
firstTouchAt: new Date(), firstTouchAt: new Date(),
}, },
}); });
this.logPromoMetric(promo.id, 'ATTRIBUTION', meta, { userId });
} }
await this.applyPromoSourceToUser(userId, promo); const sourceApplied = await this.applyPromoSourceToUser(userId, promo, meta);
return promo; if (sourceApplied) {
this.logPromoMetric(promo.id, 'REGISTER', meta, { userId });
}
return { promo, sourceApplied };
} }
private mapOwnerUser(user: PromoRow['ownerUser']) { private mapOwnerUser(user: PromoRow['ownerUser']) {
@@ -393,6 +421,7 @@ export class PromoCodeService {
countScan?: boolean; countScan?: boolean;
}, },
userId?: bigint, userId?: bigint,
meta?: PromoTouchMeta,
) { ) {
const promoCode = input.promoCode?.trim().toUpperCase(); const promoCode = input.promoCode?.trim().toUpperCase();
const qrcodeId = input.qrcodeId?.trim(); const qrcodeId = input.qrcodeId?.trim();
@@ -433,7 +462,17 @@ export class PromoCodeService {
attributed = true; 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({ return serializeBigInt({
@@ -450,6 +489,7 @@ export class PromoCodeService {
async applyPromoSourceToUser( async applyPromoSourceToUser(
userId: bigint, userId: bigint,
promo: { id: bigint; name: string }, promo: { id: bigint; name: string },
meta?: PromoTouchMeta,
): Promise<boolean> { ): Promise<boolean> {
const user = await this.prisma.user.findUnique({ const user = await this.prisma.user.findUnique({
where: { id: userId }, where: { id: userId },
@@ -470,6 +510,132 @@ export class PromoCodeService {
return true; 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 }) { private async statsFromRow(row: { id: bigint; scanCount: number; orderCount: number }) {
const scanCount = row.scanCount; const scanCount = row.scanCount;
const orderCount = row.orderCount; 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 { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import { GeoModule } from '../../common/geo/geo.module';
import { IntegrationsModule } from '../../integrations/integrations.module'; import { IntegrationsModule } from '../../integrations/integrations.module';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { AdminPromoCodeController } from './admin-promo-code.controller'; import { AdminPromoCodeController } from './admin-promo-code.controller';
import { PromoCodeService } from './promo-code.service'; import { PromoCodeService } from './promo-code.service';
import { PromoMetricLogService } from './promo-metric-log.service';
@Module({ @Module({
imports: [ imports: [
GeoModule,
IntegrationsModule, IntegrationsModule,
JwtModule.register({ JwtModule.register({
secret: process.env.JWT_SECRET || 'dukang-prev1-dev-secret', secret: process.env.JWT_SECRET || 'dukang-prev1-dev-secret',
@@ -15,7 +18,7 @@ import { PromoCodeService } from './promo-code.service';
}), }),
], ],
controllers: [AdminPromoCodeController], controllers: [AdminPromoCodeController],
providers: [PromoCodeService, JwtAuthGuard, HqAuthGuard], providers: [PromoCodeService, PromoMetricLogService, JwtAuthGuard, HqAuthGuard],
exports: [PromoCodeService], exports: [PromoCodeService],
}) })
export class PromoModule {} export class PromoModule {}
@@ -1581,13 +1581,15 @@ export class SettlementService {
return { billDate: billDate.toISOString().slice(0, 10), skipped: true, reason: '已打款' }; return { billDate: billDate.toISOString().slice(0, 10), skipped: true, reason: '已打款' };
} }
// T+3:纳入「账单日」当天完成的同城/跨城订单(完成日 = 今天 − lagDays)
const orders = await this.prisma.order.findMany({ const orders = await this.prisma.order.findMany({
where: { where: {
status: 'COMPLETED',
payStatus: 'PAID', payStatus: 'PAID',
deliveryType: { in: ['LOCAL', 'CROSS_CITY'] }, deliveryType: { in: ['LOCAL', 'CROSS_CITY'] },
paidAt: { gte: start, lt: end }, completedAt: { gte: start, lt: end },
}, },
orderBy: { paidAt: 'asc' }, orderBy: { completedAt: 'asc' },
}); });
if (orders.length === 0 && !existing) { if (orders.length === 0 && !existing) {
@@ -1633,7 +1635,8 @@ export class SettlementService {
deliveryType: o.deliveryType, deliveryType: o.deliveryType,
payAmount: o.payAmount, payAmount: o.payAmount,
wineryAmount: round2(Number(o.payAmount) * rate), wineryAmount: round2(Number(o.payAmount) * rate),
paidAt: o.paidAt!, // 明细仍保留支付时间;账期窗口按 completedAt 归属
paidAt: o.paidAt ?? o.completedAt!,
})), })),
}); });
} }

Some files were not shown because too many files have changed in this diff Show More