Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b40eb83f27 | |||
| f5e523a425 | |||
| b21403a47c | |||
| f9a5b6e81a | |||
| 1c787ed576 | |||
| aca3df4bf2 | |||
| fa0d928980 | |||
| 6f427cb553 | |||
| a7d55b7293 | |||
| f09e00e16f | |||
| aa0e140fec | |||
| f8d8e2d6d5 | |||
| 9859abba7e | |||
| c89560e4f8 | |||
| ffed560d43 | |||
| 5199b495e0 | |||
| 82b98aa4e3 | |||
| 7dffd40f3f | |||
| 0304201e80 | |||
| dd4362c397 | |||
| 87515decb3 | |||
| 28ef916a22 | |||
| 39e38aac7e | |||
| daf534b4db | |||
| 118993a01f | |||
| 8f361981bf | |||
| 4bb6ae20cd | |||
| ad757e9ba8 | |||
| e63b57db19 | |||
| fb4432b307 | |||
| ab44515e8b | |||
| 78365d648c | |||
| e9eddbb26e | |||
| c00ca3620d | |||
| 4d76ee6d0e | |||
| 2dbe665bdc | |||
| 1bc3bec977 | |||
| 81a6e3674b |
@@ -11,7 +11,6 @@
|
|||||||
"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,6 +15,8 @@ type OssUploadProps = {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DEFAULT_MAX_MB = 10;
|
||||||
|
|
||||||
export default function OssUpload({
|
export default function OssUpload({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -22,6 +24,7 @@ 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);
|
||||||
@@ -31,6 +34,12 @@ 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 });
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
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';
|
||||||
|
|
||||||
@@ -14,24 +9,14 @@ 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', prepared);
|
formData.append('file', file);
|
||||||
formData.append('bizType', options.bizType);
|
formData.append('bizType', options.bizType);
|
||||||
formData.append('mediaType', mediaType);
|
formData.append('mediaType', mediaType);
|
||||||
|
|
||||||
|
|||||||
@@ -16,12 +16,10 @@ 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';
|
||||||
@@ -52,7 +50,6 @@ 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);
|
||||||
@@ -67,13 +64,6 @@ 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',
|
||||||
() => {
|
() => {
|
||||||
@@ -88,7 +78,7 @@ export default function DevPlanTasksPage() {
|
|||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
form.setFieldsValue({ content: '', type: 'BUG', status: 'TODO', supportTicketId: undefined });
|
form.setFieldsValue({ content: '', type: 'BUG', status: 'TODO' });
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,11 +101,7 @@ 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({
|
body: JSON.stringify({ content: values.content, type: values.type }),
|
||||||
content: values.content,
|
|
||||||
type: values.type,
|
|
||||||
supportTicketId: values.supportTicketId || undefined,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
message.success('已创建');
|
message.success('已创建');
|
||||||
}
|
}
|
||||||
@@ -319,20 +305,7 @@ 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>
|
||||||
|
|
||||||
|
|||||||
@@ -13,16 +13,11 @@ type Row = {
|
|||||||
settleAmount: number;
|
settleAmount: number;
|
||||||
channel?: RedeemChannel;
|
channel?: RedeemChannel;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
user?: { userNo: string; phone: string | null; nickname?: string | null };
|
user?: { userNo: string; phone: 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>>({});
|
||||||
@@ -55,19 +50,7 @@ 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}` },
|
||||||
@@ -130,7 +113,7 @@ export default function RedeemRecordsPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
scroll={{ x: 1300 }}
|
scroll={{ x: 1100 }}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -155,19 +138,6 @@ export default function RedeemRecordsPage() {
|
|||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="核销额">¥{String(detail.amount)}</Descriptions.Item>
|
<Descriptions.Item label="核销额">¥{String(detail.amount)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="结算额">¥{String(detail.settleAmount)}</Descriptions.Item>
|
<Descriptions.Item label="结算额">¥{String(detail.settleAmount)}</Descriptions.Item>
|
||||||
{detail.user && typeof detail.user === 'object' ? (
|
|
||||||
<>
|
|
||||||
<Descriptions.Item label="用户编号">
|
|
||||||
{String((detail.user as { userNo?: string }).userNo ?? '—')}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="用户昵称">
|
|
||||||
{String((detail.user as { nickname?: string | null }).nickname ?? '—')}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="用户手机">
|
|
||||||
{maskPhone((detail.user as { phone?: string | null }).phone)}
|
|
||||||
</Descriptions.Item>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
|
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ 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,
|
||||||
@@ -38,7 +37,6 @@ import {
|
|||||||
type DevPlanVersionDto,
|
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';
|
||||||
@@ -66,17 +64,6 @@ 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] }),
|
||||||
);
|
);
|
||||||
@@ -93,7 +80,6 @@ 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],
|
||||||
@@ -168,7 +154,6 @@ 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),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -232,7 +217,6 @@ 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);
|
||||||
@@ -249,7 +233,6 @@ 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),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -468,16 +451,6 @@ 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',
|
||||||
@@ -589,9 +562,6 @@ 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>
|
||||||
@@ -607,7 +577,11 @@ export default function SupportTicketsPage() {
|
|||||||
isSuperAdmin
|
isSuperAdmin
|
||||||
? {
|
? {
|
||||||
selectedRowKeys,
|
selectedRowKeys,
|
||||||
onChange: (keys) => setSelectedRowKeys(keys as string[]),
|
onChange: (keys, rows) => {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
@@ -633,9 +607,6 @@ 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>
|
||||||
@@ -894,13 +865,10 @@ 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', priority: 'NORMAL', attachmentUrls: [''] }}>
|
<Form form={createForm} layout="vertical" initialValues={{ ticketType: 'BUG', 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>
|
||||||
@@ -1004,9 +972,6 @@ 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>
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ export default function WineryBillsPage() {
|
|||||||
酒厂对账单
|
酒厂对账单
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
T+3:每日 8:00 汇总 3 天前(自然日)已完成的同城/跨城订单(实付 × {ratePct}%);未打款红色、已打款绿色,可展开订单明细
|
每日 8:00 汇总昨日已付订单(实付 × {ratePct}%);未打款红色、已打款绿色,可展开订单明细
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Space>
|
</Space>
|
||||||
{canEditWineryBank ? (
|
{canEditWineryBank ? (
|
||||||
|
|||||||
@@ -212,14 +212,6 @@ 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
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -77,6 +79,10 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
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;
|
||||||
|
|
||||||
@@ -16,23 +11,13 @@ 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', prepared);
|
formData.append('file', file);
|
||||||
formData.append('bizType', options.bizType);
|
formData.append('bizType', options.bizType);
|
||||||
formData.append('mediaType', mediaType);
|
formData.append('mediaType', mediaType);
|
||||||
|
|
||||||
|
|||||||
@@ -168,22 +168,12 @@ 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<PartnerSessionPayload>('PARTNER_H5', '/partner/auth/login/sms', {
|
const data = await request<PartnerSessionPayload>('PARTNER_H5', '/partner/auth/login/sms', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ phone: trimmedPhone, code: trimmedCode }),
|
body: JSON.stringify({ phone, code }),
|
||||||
silent: true,
|
silent: true,
|
||||||
});
|
});
|
||||||
saveRememberedSession(data);
|
saveRememberedSession(data);
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
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;
|
||||||
|
|
||||||
@@ -21,12 +16,8 @@ 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', compressed);
|
formData.append('file', file);
|
||||||
formData.append('bizType', bizType);
|
formData.append('bizType', bizType);
|
||||||
formData.append('mediaType', 'IMAGE');
|
formData.append('mediaType', 'IMAGE');
|
||||||
|
|
||||||
|
|||||||
@@ -140,22 +140,12 @@ 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: trimmedPhone, code: trimmedCode }),
|
body: JSON.stringify({ phone, code }),
|
||||||
});
|
});
|
||||||
saveRememberedSession(data);
|
saveRememberedSession(data);
|
||||||
applySession(data);
|
applySession(data);
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
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';
|
||||||
|
|
||||||
@@ -20,15 +15,8 @@ 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', prepared);
|
formData.append('file', file);
|
||||||
formData.append('bizType', options.bizType);
|
formData.append('bizType', options.bizType);
|
||||||
formData.append('mediaType', mediaType);
|
formData.append('mediaType', mediaType);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@dukang/mini-user",
|
"name": "@dukang/mini-user",
|
||||||
"version": "3.4.13",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "杜康好客 · C 端用户微信小程序(Taro)",
|
"description": "杜康好客 · C 端用户微信小程序(Taro)",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ 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',
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
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,7 +20,6 @@ export default function ProductCarousel({
|
|||||||
const [activeIndex, setActiveIndex] = useState(0);
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
const prefix =
|
const prefix =
|
||||||
variant === 'store' ? 'store-detail-carousel' : variant === 'home' ? 'home-carousel' : 'detail-carousel';
|
variant === 'store' ? 'store-detail-carousel' : variant === 'home' ? 'home-carousel' : 'detail-carousel';
|
||||||
const imageMode = variant === 'store' ? 'aspectFit' : 'aspectFill';
|
|
||||||
|
|
||||||
function previewAt(index: number) {
|
function previewAt(index: number) {
|
||||||
const urls = slides.filter(Boolean);
|
const urls = slides.filter(Boolean);
|
||||||
@@ -42,7 +41,7 @@ export default function ProductCarousel({
|
|||||||
<Image
|
<Image
|
||||||
className={`${prefix}-image`}
|
className={`${prefix}-image`}
|
||||||
src={src}
|
src={src}
|
||||||
mode={imageMode}
|
mode="aspectFill"
|
||||||
alt={alt}
|
alt={alt}
|
||||||
onClick={previewable ? () => previewAt(index) : undefined}
|
onClick={previewable ? () => previewAt(index) : undefined}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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';
|
||||||
@@ -39,7 +38,6 @@ export default function WechatShareBootstrap() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void capturePromoSceneAndTouchScan();
|
void capturePromoSceneAndTouchScan();
|
||||||
initClientVersionChecks();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
import Taro from '@tarojs/taro';
|
|
||||||
import { fetchClientConfig } from './pay-wechat';
|
|
||||||
|
|
||||||
/** 与 package.json version 同步,供服务端 minClientVersion 比对 */
|
|
||||||
export const APP_VERSION = '3.4.13';
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function checkClientVersionGate() {
|
|
||||||
try {
|
|
||||||
const config = await fetchClientConfig();
|
|
||||||
const min = config.minClientVersion?.trim();
|
|
||||||
if (min && compareSemver(APP_VERSION, min) < 0) {
|
|
||||||
await Taro.showModal({
|
|
||||||
title: '版本过低',
|
|
||||||
content: '当前小程序版本过低,请更新至最新版本后继续使用。',
|
|
||||||
showCancel: false,
|
|
||||||
confirmText: '我知道了',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} 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();
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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,15 +69,12 @@ 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,
|
filePath: tempFilePath,
|
||||||
name: 'file',
|
name: 'file',
|
||||||
formData: {
|
formData: {
|
||||||
bizType: 'AVATAR',
|
bizType: 'AVATAR',
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
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 }];
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
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`);
|
|
||||||
}
|
|
||||||
@@ -141,14 +141,6 @@ 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,19 +6,8 @@ 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 {
|
||||||
@@ -55,15 +44,8 @@ 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> = {
|
||||||
@@ -96,41 +78,11 @@ 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((data) => {
|
.then(setOrder)
|
||||||
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]);
|
||||||
|
|
||||||
@@ -138,10 +90,7 @@ export default function OrderDetailPage() {
|
|||||||
if (!orderId) return;
|
if (!orderId) return;
|
||||||
// 从微信确认收货组件返回后刷新
|
// 从微信确认收货组件返回后刷新
|
||||||
request<OrderDetail>(`/trade/orders/${orderId}`)
|
request<OrderDetail>(`/trade/orders/${orderId}`)
|
||||||
.then((data) => {
|
.then(setOrder)
|
||||||
setOrder(data);
|
|
||||||
void loadOrderTrack(data.delivery, data.status);
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -150,18 +99,6 @@ 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 || '杜康商品';
|
||||||
@@ -197,11 +134,6 @@ 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;
|
||||||
|
|
||||||
@@ -279,34 +211,6 @@ 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>
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
export default definePageConfig({
|
|
||||||
navigationStyle: 'custom',
|
|
||||||
navigationBarTitleText: '物流详情',
|
|
||||||
});
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
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,6 +11,7 @@ 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';
|
||||||
@@ -166,11 +167,12 @@ 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" previewable />
|
<ProductCarousel images={carouselImages} alt={product.name} variant="detail" />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="product-detail-info">
|
<View className="product-detail-info">
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ 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,
|
||||||
@@ -156,7 +154,6 @@ export default function StoreDetailPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [loadError, setLoadError] = useState('');
|
const [loadError, setLoadError] = useState('');
|
||||||
const [headerSolid, setHeaderSolid] = useState(false);
|
const [headerSolid, setHeaderSolid] = useState(false);
|
||||||
const [expandedPackages, setExpandedPackages] = useState<Record<number, boolean>>({});
|
|
||||||
const storeRef = useRef<Store | null>(null);
|
const storeRef = useRef<Store | null>(null);
|
||||||
storeRef.current = store;
|
storeRef.current = store;
|
||||||
|
|
||||||
@@ -274,7 +271,6 @@ 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('无法拨打电话'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,9 +374,7 @@ 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">
|
<Text className="store-detail-meta store-detail-meta--flex">电话: {store.phone}</Text>
|
||||||
电话: {maskPhone(store.phone)}
|
|
||||||
</Text>
|
|
||||||
<Text className="store-detail-action" onClick={callStore}>
|
<Text className="store-detail-action" onClick={callStore}>
|
||||||
拨打
|
拨打
|
||||||
</Text>
|
</Text>
|
||||||
@@ -405,32 +399,15 @@ export default function StoreDetailPage() {
|
|||||||
{store.packages && store.packages.length > 0 ? (
|
{store.packages && store.packages.length > 0 ? (
|
||||||
<View className="store-detail-section">
|
<View className="store-detail-section">
|
||||||
<Text className="store-detail-section-title">门店套餐</Text>
|
<Text className="store-detail-section-title">门店套餐</Text>
|
||||||
{store.packages.map((pkg, index) => {
|
{store.packages.map((pkg, index) => (
|
||||||
const expanded = !!expandedPackages[index];
|
<View key={`${pkg.name}-${index}`} className="store-detail-package-card">
|
||||||
const bodyText = `${formatRedeemAmountYuan(pkg.price)} 元 · ${pkg.dishes}`;
|
|
||||||
const longBody = bodyText.length > 48 || (pkg.otherNotes?.length ?? 0) > 40;
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
key={`${pkg.name}-${index}`}
|
|
||||||
className={`store-detail-package-card${expanded || !longBody ? '' : ' store-detail-package-card--collapsed'}`}
|
|
||||||
>
|
|
||||||
{pkg.imageUrl ? (
|
{pkg.imageUrl ? (
|
||||||
<Image className="store-detail-package-img" src={pkg.imageUrl} mode="aspectFill" />
|
<Image className="store-detail-package-img" src={pkg.imageUrl} mode="aspectFill" />
|
||||||
) : null}
|
) : null}
|
||||||
<View className="store-detail-package-head">
|
<Text className="store-detail-package-name">{pkg.name}</Text>
|
||||||
<Text className="store-detail-package-name">{pkg.name}</Text>
|
<Text className="store-detail-package-body">
|
||||||
{longBody ? (
|
{formatRedeemAmountYuan(pkg.price)} 元 · {pkg.dishes}
|
||||||
<Text
|
</Text>
|
||||||
className="store-detail-package-toggle"
|
|
||||||
onClick={() =>
|
|
||||||
setExpandedPackages((prev) => ({ ...prev, [index]: !prev[index] }))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{expanded ? '收起' : '展开'}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
<Text className="store-detail-package-body">{bodyText}</Text>
|
|
||||||
{pkg.usableTime ? (
|
{pkg.usableTime ? (
|
||||||
<Text className="store-detail-package-meta">使用时间:{pkg.usableTime}</Text>
|
<Text className="store-detail-package-meta">使用时间:{pkg.usableTime}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -438,8 +415,7 @@ export default function StoreDetailPage() {
|
|||||||
<Text className="store-detail-package-meta">说明:{pkg.otherNotes}</Text>
|
<Text className="store-detail-package-meta">说明:{pkg.otherNotes}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -488,188 +488,3 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,15 +10,13 @@
|
|||||||
.store-detail-carousel-wrap {
|
.store-detail-carousel-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 200px;
|
height: 280px;
|
||||||
max-height: 360px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--color-surface-container);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-detail-carousel {
|
.store-detail-carousel {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 280px;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-detail-carousel-item,
|
.store-detail-carousel-item,
|
||||||
@@ -243,27 +241,6 @@
|
|||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-detail-package-card--collapsed .store-detail-package-body,
|
|
||||||
.store-detail-package-card--collapsed .store-detail-package-meta {
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-detail-package-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-detail-package-toggle {
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--color-heritage-red);
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-detail-package-img {
|
.store-detail-package-img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 160px;
|
height: 160px;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -33,24 +33,11 @@ export const SUPPORT_TICKET_STATUS_LABELS: Record<SupportTicketStatusDto, string
|
|||||||
PASSED: '通过',
|
PASSED: '通过',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 技术支持工单优先级 */
|
|
||||||
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 {
|
||||||
id: string;
|
id: string;
|
||||||
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;
|
||||||
@@ -72,7 +59,6 @@ export interface CreateSupportTicketRequest {
|
|||||||
content?: string;
|
content?: string;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
attachmentUrls?: string[];
|
attachmentUrls?: string[];
|
||||||
priority?: SupportTicketPriorityDto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateSupportTicketRequest {
|
export interface UpdateSupportTicketRequest {
|
||||||
@@ -81,7 +67,6 @@ export interface UpdateSupportTicketRequest {
|
|||||||
content?: string;
|
content?: string;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
attachmentUrls?: string[];
|
attachmentUrls?: string[];
|
||||||
priority?: SupportTicketPriorityDto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RejectSupportTicketRequest {
|
export interface RejectSupportTicketRequest {
|
||||||
|
|||||||
@@ -70,30 +70,6 @@ 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箱,待总部确认推单/自配送',
|
||||||
|
|||||||
@@ -48,8 +48,6 @@ export type ClientRuntimeConfig = {
|
|||||||
banners: string[];
|
banners: string[];
|
||||||
footerUrl: string | null;
|
footerUrl: string | null;
|
||||||
};
|
};
|
||||||
/** 小程序最低兼容版本(semver,如 3.4.13);客户端低于此值时提示更新 */
|
|
||||||
minClientVersion?: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 是否展示微信授权入口 */
|
/** 是否展示微信授权入口 */
|
||||||
|
|||||||
@@ -11,8 +11,7 @@
|
|||||||
"./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"
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
/** 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);
|
|
||||||
}
|
|
||||||
Generated
-3
@@ -29,9 +29,6 @@ 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)
|
||||||
|
|||||||
@@ -109,13 +109,6 @@ enum SupportTicketStatus {
|
|||||||
PASSED
|
PASSED
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SupportTicketPriority {
|
|
||||||
LOW
|
|
||||||
NORMAL
|
|
||||||
HIGH
|
|
||||||
URGENT
|
|
||||||
}
|
|
||||||
|
|
||||||
enum DevPlanTaskType {
|
enum DevPlanTaskType {
|
||||||
BUG
|
BUG
|
||||||
REQUIREMENT
|
REQUIREMENT
|
||||||
@@ -653,7 +646,6 @@ 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)
|
||||||
|
|||||||
@@ -6,12 +6,9 @@ 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, CourierModule],
|
imports: [IntegrationsModule, TradeModule, PrismaModule],
|
||||||
providers: [DeliveryCallbackService],
|
|
||||||
controllers: [
|
controllers: [
|
||||||
WechatPayCallbackController,
|
WechatPayCallbackController,
|
||||||
WechatRefundCallbackController,
|
WechatRefundCallbackController,
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
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,27 +1,71 @@
|
|||||||
import { Body, Controller, Param, Post } from '@nestjs/common';
|
import { Body, Controller, Post } from '@nestjs/common';
|
||||||
import { DeliveryCallbackService } from './delivery-callback.service';
|
import { TradeService } from '../modules/trade/trade.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')
|
@Controller('callbacks/delivery')
|
||||||
export class DeliveryCallbackController {
|
export class DeliveryCallbackController {
|
||||||
constructor(private readonly deliveryCallbackService: DeliveryCallbackService) {}
|
constructor(
|
||||||
|
private readonly tradeService: TradeService,
|
||||||
|
private readonly courier: CourierService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
/** 小飞侠/物流路由变化回调(适配器入口) */
|
@Post('track')
|
||||||
@Post('courier/:provider/track')
|
async track(@Body() body: { orderNo?: string; orderId?: string; status?: string }) {
|
||||||
trackByProvider(@Param('provider') provider: string, @Body() body: unknown) {
|
const baseLog = {
|
||||||
return this.deliveryCallbackService.handleTrackCallback(
|
scene: 'TRACK_CALLBACK',
|
||||||
provider,
|
requestUrl: '/api/v1/callbacks/delivery/track',
|
||||||
body,
|
requestBody: body as Record<string, unknown>,
|
||||||
`/api/v1/callbacks/courier/${provider}/track`,
|
};
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 兼容旧路径,默认按小飞侠解析 */
|
if (!body.orderId && !body.orderNo) {
|
||||||
@Post('delivery/track')
|
await logCourierCall(this.prisma, {
|
||||||
trackLegacy(@Body() body: unknown) {
|
...baseLog,
|
||||||
return this.deliveryCallbackService.handleTrackCallback(
|
status: 'FAILED',
|
||||||
'xfx',
|
errorMessage: '缺少 orderId / orderNo',
|
||||||
body,
|
});
|
||||||
'/api/v1/callbacks/delivery/track',
|
return this.courier.buildTrackCallbackResponse(false);
|
||||||
);
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,15 +116,6 @@ 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,7 +9,6 @@ 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, XiaofeixiaProvider],
|
exports: [CourierService, CourierConfigService, COURIER_PROVIDER],
|
||||||
})
|
})
|
||||||
export class CourierModule {}
|
export class CourierModule {}
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ import type {
|
|||||||
ShipmentQuery,
|
ShipmentQuery,
|
||||||
TrackCallbackResponse,
|
TrackCallbackResponse,
|
||||||
TrackNode,
|
TrackNode,
|
||||||
TrackCallbackPayload,
|
|
||||||
CourierMappedOrderStatus,
|
|
||||||
} from './courier.types';
|
} from './courier.types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,10 +45,6 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -62,12 +56,4 @@ 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,13 +109,6 @@ 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;
|
||||||
|
|
||||||
@@ -124,10 +117,7 @@ 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,7 +6,6 @@ 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,9 +12,7 @@ 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';
|
||||||
@@ -123,59 +121,6 @@ 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:酒厂日账单(T+3:3 天前已完成订单)+ 门店日账单(昨日核销)
|
* - 每日 08:00:酒厂日账单 + 门店日账单(统计昨日 00:00~今日 00:00)
|
||||||
* - 每月 1 日 08:00:合伙人上一自然月账单 + 物流承运商上一自然月对账
|
* - 每月 1 日 08:00:合伙人上一自然月账单 + 物流承运商上一自然月对账
|
||||||
* - 工作日 18:05:门店提现 T+0 审完预警(FIN-003)
|
* - 工作日 18:05:门店提现 T+0 审完预警(FIN-003)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ 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,
|
||||||
@@ -20,7 +19,6 @@ 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,
|
||||||
|
|||||||
@@ -11,10 +11,6 @@ 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()
|
||||||
@@ -50,10 +46,6 @@ 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 {
|
||||||
@@ -80,10 +72,6 @@ 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 {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { SupportTicketStatus, SupportTicketType, SupportTicketPriority } from '@prisma/client';
|
import type { SupportTicketStatus, SupportTicketType } 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 { mapSupportTicketTypeToDevPlanTask } from '@dukang/shared-types';
|
||||||
@@ -68,7 +68,6 @@ 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,
|
||||||
@@ -179,7 +178,6 @@ 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));
|
||||||
}
|
}
|
||||||
@@ -249,9 +247,6 @@ 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({
|
||||||
|
|||||||
@@ -9,10 +9,8 @@ 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, type TrackNode } from '../../integrations/courier/courier.types';
|
import { CourierPayMode } 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';
|
||||||
|
|
||||||
@@ -34,7 +32,6 @@ 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,
|
||||||
) {}
|
) {}
|
||||||
@@ -249,30 +246,13 @@ 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: {
|
include: { delivery: true },
|
||||||
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 base;
|
return { nodes: [], manualQueryUrl: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const isXfx =
|
if (order.delivery.provider === 'XFX' && (order.delivery.trackingNo || order.orderNo)) {
|
||||||
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
|
||||||
? {
|
? {
|
||||||
@@ -281,44 +261,27 @@ export class FulfillmentService {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
const shipmentQuery = {
|
const nodes = await this.courier.getTrack(
|
||||||
trackingNumber: order.delivery.trackingNo ?? undefined,
|
{
|
||||||
outNumber: order.orderNo,
|
trackingNumber: order.delivery.trackingNo ?? undefined,
|
||||||
|
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 {
|
||||||
...base,
|
nodes: [],
|
||||||
manualQueryUrl: order.delivery.manualQueryUrl,
|
manualQueryUrl: order.delivery.manualQueryUrl,
|
||||||
provider: order.delivery.provider,
|
provider: order.delivery.provider,
|
||||||
trackingNo: order.delivery.trackingNo,
|
trackingNo: order.delivery.trackingNo,
|
||||||
@@ -326,119 +289,6 @@ 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,10 +1692,6 @@ 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: {
|
||||||
|
|||||||
@@ -1581,15 +1581,13 @@ 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'] },
|
||||||
completedAt: { gte: start, lt: end },
|
paidAt: { gte: start, lt: end },
|
||||||
},
|
},
|
||||||
orderBy: { completedAt: 'asc' },
|
orderBy: { paidAt: 'asc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (orders.length === 0 && !existing) {
|
if (orders.length === 0 && !existing) {
|
||||||
@@ -1635,8 +1633,7 @@ 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),
|
||||||
// 明细仍保留支付时间;账期窗口按 completedAt 归属
|
paidAt: o.paidAt!,
|
||||||
paidAt: o.paidAt ?? o.completedAt!,
|
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,12 +44,6 @@
|
|||||||
|------|------|------|
|
|------|------|------|
|
||||||
| **3.4.12** | 2026-08-04 | 售后退款回滚、mini-user 门店详情、酒厂 T+3、门店多笔提现、开发计划批量编辑/审批企微派发、技术支持编辑/附件/批量改状态、套餐 imageUrl;开发设计见 [`杜康好客-v3.4.12-工单迭代开发文档.md`](./杜康好客-v3.4.12-工单迭代开发文档.md) |
|
| **3.4.12** | 2026-08-04 | 售后退款回滚、mini-user 门店详情、酒厂 T+3、门店多笔提现、开发计划批量编辑/审批企微派发、技术支持编辑/附件/批量改状态、套餐 imageUrl;开发设计见 [`杜康好客-v3.4.12-工单迭代开发文档.md`](./杜康好客-v3.4.12-工单迭代开发文档.md) |
|
||||||
|
|
||||||
### 0.5 变更:3.4.13 体验优化
|
|
||||||
|
|
||||||
| 版本 | 日期 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| **3.4.13** | 2026-08-05 | 推广码归因统计、核销用户信息、技术支持工单优先级、mini-user 门店/商品/提货/版本/**物流增强(签收照/拨号/ETA/路由回调签收→已完成)**、H5 登录校验、合伙人微信暂停禁登、**OSS 图片超 10MB 客户端压缩**;开发设计见 [`杜康好客-v3.4.13-体验优化开发文档.md`](./杜康好客-v3.4.13-体验优化开发文档.md) |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 背景与目标
|
## 1. 背景与目标
|
||||||
|
|||||||
+1
-19
@@ -330,28 +330,10 @@ C2~C7、C14 见 §1.3。
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. v3.4.13 体验优化(2026-08-05)
|
## 10. 变更记录
|
||||||
|
|
||||||
| 项 | 状态 | 说明 |
|
|
||||||
|----|------|------|
|
|
||||||
| 推广码 attributionCount | ✅ | HQ 详情统计卡 |
|
|
||||||
| 核销用户信息 | ✅ | RedeemRecordsPage 列表+详情 |
|
|
||||||
| 技术支持优先级 | ✅ | Prisma + shared-types + HQ UI |
|
|
||||||
| 合伙人微信暂停禁登 | ✅ | loginPartnerWechat |
|
|
||||||
| H5 登录前端校验 | ✅ | partner/shop LoginPage |
|
|
||||||
| mini-user 门店体验 | ✅ | 电话脱敏/埋点、门头 aspectFit、套餐折叠 |
|
|
||||||
| mini-user 商品/提货 | ✅ | 去分享、首图 preview、提货确认弹框 |
|
|
||||||
| mini-user 版本/物流 | ✅ | 摘要 + 签收照 + 拨号 + 时间线 + ETA;回调签收→**COMPLETED** |
|
|
||||||
| OSS 大图压缩 | ✅ | shared-ui compressImage;四端 upload + mini 头像 |
|
|
||||||
| 文档 | ✅ | PRD §0.5 + v3.4.13 开发文档 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. 变更记录
|
|
||||||
|
|
||||||
| 日期 | 说明 |
|
| 日期 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 2026-08-05 | v3.4.13 体验优化(20 条 ST) |
|
|
||||||
| 2026-08-04 | v3.4.12 工单迭代(退款/财务/C端/开发计划/技术支持/套餐) |
|
| 2026-08-04 | v3.4.12 工单迭代(退款/财务/C端/开发计划/技术支持/套餐) |
|
||||||
| 2026-08-04 | v3.4.11 开发计划 + 企微智能机器人/消息推送 + 角色权限重构 |
|
| 2026-08-04 | v3.4.11 开发计划 + 企微智能机器人/消息推送 + 角色权限重构 |
|
||||||
| 2026-07-12 | **P0 已执行**:C2~C7、C14 代码与文档对齐;§1 改为计划+状态表 |
|
| 2026-07-12 | **P0 已执行**:C2~C7、C14 代码与文档对齐;§1 改为计划+状态表 |
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
# 杜康好客 · v3.4.13 体验优化开发文档
|
|
||||||
|
|
||||||
> 版本:**v3.4.13** · 日期:2026-08-05
|
|
||||||
> 需求源:20 条 ST 工单(体验优化 / Bug / 客户端验证)
|
|
||||||
|
|
||||||
## 1. 范围
|
|
||||||
|
|
||||||
| 模块 | 内容 |
|
|
||||||
|------|------|
|
|
||||||
| admin-web | 推广码 attributionCount;核销记录用户信息;技术支持工单优先级;**大图上传前压缩** |
|
|
||||||
| mini-user | 门店电话脱敏+拨打埋点;门头/套餐展示;商品去分享+首图 preview;提货确认弹框;版本更新提示;**物流增强(签收照/拨号/时间线/ETA)**;**头像超 10MB 压缩** |
|
|
||||||
| h5-partner / h5-shop / h5-user | 登录 phone/code 前端校验;**OSS 图片超 10MB 自动 canvas 压缩后上传** |
|
|
||||||
| 后端 | 工单 priority;client-config minClientVersion;合伙人微信暂停禁登;**Courier 适配器(100108/100301/路由回调)** |
|
|
||||||
|
|
||||||
## 2. ST 映射
|
|
||||||
|
|
||||||
| ST | 标题 | 状态 |
|
|
||||||
|----|------|------|
|
|
||||||
| ST1785925037781309 | 总部端-推广码数据跟踪优化 | ✅ attributionCount 统计卡 |
|
|
||||||
| ST1785924286682833 | 用户端-门店电话加密+拨打埋点 | ✅ maskPhone + store_phone_call |
|
|
||||||
| ST1785921693982470 | 技术支持-工单优先级 | ✅ priority 枚举 + HQ UI |
|
|
||||||
| ST1785921585900725 | 门店端扫一扫授权异常 | ✅ 已有(v3.4.12 前) |
|
|
||||||
| ST1785907536648201 | 版本不对提示更新 | ✅ UpdateManager + minClientVersion |
|
|
||||||
| ST1785906800359657 | 合伙人暂停后禁登 | ✅ loginPartnerWechat status |
|
|
||||||
| ST1785906592340255 | PARTNER_H5 login 校验 | ✅ 前端空字段拦截 |
|
|
||||||
| ST1785906390321653 | 现场提货提交确认弹框 | ✅ showModal |
|
|
||||||
| ST1785905773501871 | 核销记录用户信息 | ✅ 列表+详情 |
|
|
||||||
| ST1785904849234806 | 工单中心 | ✅ 已有 |
|
|
||||||
| ST1785904076632841 | 门店列表开城合伙人 | ✅ 已有 |
|
|
||||||
| ST1785902173093977 | 去掉商品详情分享按钮 | ✅ 移除 ShareNavButton |
|
|
||||||
| ST1785902141731113 | 商品首图大图 | ✅ previewable |
|
|
||||||
| ST1785901870913349 | 门店列表营业时间 | ✅ 已有 |
|
|
||||||
| ST1785901838948572 | SHOP 未绑定门店 | ✅ 已有 |
|
|
||||||
| ST1785901775231811 | 门店套餐遮挡 | ✅ 折叠/行数限制 |
|
|
||||||
| ST1785901711627824 | SHOP login 校验 | ✅ 前端空字段拦截 |
|
|
||||||
| ST1785901314893145 | 门头照裁剪 | ✅ aspectFit + preview |
|
|
||||||
| ST1785939375449985 | 订单物流追踪页 | ✅ 签收照 + 拨号 + 时间线 + ETA + 路由回调 |
|
|
||||||
| — | 上传图片超 10MB 先压缩 | ✅ `@dukang/shared-ui/compressImage` + 各端 upload |
|
|
||||||
|
|
||||||
## 3. 后端 API / 配置
|
|
||||||
|
|
||||||
| 方法 | 路径 / 配置 | 说明 |
|
|
||||||
|------|-------------|------|
|
|
||||||
| GET | `/common/client-config` | 新增 `minClientVersion`(env `MINI_USER_MIN_VERSION`) |
|
|
||||||
| — | `CommonSupportTicket.priority` | `LOW \| NORMAL \| HIGH \| URGENT`,默认 NORMAL |
|
|
||||||
| — | `loginPartnerWechat` | 非 ACTIVE 账号抛出「合伙人账号已停用」 |
|
|
||||||
| GET | `/trade/orders/:id/track` | 聚合:`nodes`(旧→新)、`signPhotoUrls`、`estimatedArrival`;经 `CourierService` 适配小飞侠 cmd 100102/100108/100301 |
|
|
||||||
| POST | `/callbacks/courier/xfx/track` | 小飞侠路由变化回调(适配器入口) |
|
|
||||||
| POST | `/callbacks/courier/logistics/track` | 跨城物流回调占位(记录日志,后续接入) |
|
|
||||||
| POST | `/callbacks/delivery/track` | 兼容旧路径,等同 `xfx` |
|
|
||||||
|
|
||||||
### 4. mini-user 物流(ST1785939375449985)
|
|
||||||
|
|
||||||
| 页面 | 行为 |
|
|
||||||
|------|------|
|
|
||||||
| 订单详情 | **配送中**展示最新路由摘要 + 「物流详情」;物流未到时展示 **预估送达**(100301) |
|
|
||||||
| 物流详情 | 物流信息;**签收照片**(有则展示,`previewImage` 放大);**物流动态**旧→新、节点全红点亮、最新在底部 |
|
|
||||||
| 电话拨号 | 物流文案中手机号可点击 `makePhoneCall` |
|
|
||||||
| 接口 | `GET /trade/orders/:id/track`(`OrderTrackDto`);C 端禁止直连小飞侠 |
|
|
||||||
| 公共模块 | `order-logistics.ts` + `LogisticsRichText` 组件 |
|
|
||||||
|
|
||||||
#### 4.1 对外回调 URL(提供给小飞侠)
|
|
||||||
|
|
||||||
**生产**
|
|
||||||
|
|
||||||
```
|
|
||||||
POST https://api.dukanghaoke.com/api/v1/callbacks/courier/xfx/track
|
|
||||||
Content-Type: application/json
|
|
||||||
```
|
|
||||||
|
|
||||||
**测试**
|
|
||||||
|
|
||||||
```
|
|
||||||
POST https://api-test.dukanghaoke.com/api/v1/callbacks/courier/xfx/track
|
|
||||||
```
|
|
||||||
|
|
||||||
请求体:
|
|
||||||
|
|
||||||
| 字段 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| outNumber | 商户单号 |
|
|
||||||
| number | 运单号 |
|
|
||||||
| status | 1下单 2取件 3中转 4派件 5签收 7取消 |
|
|
||||||
| statusName | 中文状态 |
|
|
||||||
| trackInfo | 路由描述 |
|
|
||||||
| createTime | 路由时间 |
|
|
||||||
|
|
||||||
成功响应:`{"code":"100000","message":"success"}`
|
|
||||||
|
|
||||||
#### 4.2 Courier 适配器(`integrations/courier`)
|
|
||||||
|
|
||||||
| cmd | 能力 | Provider 方法 |
|
|
||||||
|-----|------|---------------|
|
|
||||||
| 100102 | 路由查询 | `getTrack` |
|
|
||||||
| 100108 | 签收照片 | `getSignPhotos` → OSS → `signPhotoUrls` |
|
|
||||||
| 100301 | 预估送达 | `checkDeliveryCoverage` → `estimatedArrival` |
|
|
||||||
| 回调 | 路由推送 | `parseTrackCallback` + `mapTrackStatus`(**status=5 / statusName 含「签收」→ 订单 `COMPLETED`**) |
|
|
||||||
|
|
||||||
**路由回调状态映射(小飞侠)**
|
|
||||||
|
|
||||||
| 小飞侠 status / statusName | 系统订单状态 |
|
|
||||||
|---------------------------|--------------|
|
|
||||||
| 1 下单 | `OUT_WAREHOUSE` |
|
|
||||||
| 2 取件 / 3 中转 | `SHIPPING` |
|
|
||||||
| 4 派件 | `SHIPPING` |
|
|
||||||
| **5 签收** / statusName **已签收** | **`COMPLETED`(已完成)** |
|
|
||||||
| 7 取消 | 仅记录日志,不改订单 |
|
|
||||||
|
|
||||||
## 5. 数据表
|
|
||||||
|
|
||||||
- `common_support_ticket.priority` ENUM,默认 `NORMAL`
|
|
||||||
|
|
||||||
## 6. HQ 开发计划
|
|
||||||
|
|
||||||
在 admin-web **开发计划 → 版本列表** 创建 `v3.4.13`(状态 `IN_PROGRESS`),审批 ST 后关联 `dev_plan_task`。
|
|
||||||
|
|
||||||
## 7. 验收 ACC
|
|
||||||
|
|
||||||
- [ ] 推广码详情展示 attributionCount
|
|
||||||
- [ ] 核销记录含 userNo/nickname/phone
|
|
||||||
- [ ] 技术支持可创建/筛选/编辑优先级
|
|
||||||
- [ ] 合伙人/门店登录空字段前端提示;暂停合伙人微信登录被拒
|
|
||||||
- [ ] mini-user:电话脱敏、拨打埋点、提货确认、无分享按钮、首图 preview、门头/套餐正常
|
|
||||||
- [ ] mini-user:配送中物流摘要 + 签收照预览 + 电话拨号 + 时间线旧→新全点亮 + 预估送达
|
|
||||||
- [ ] 小飞侠路由回调 `POST /callbacks/courier/xfx/track`:status=5 或 statusName 已签收 → 订单 **COMPLETED**;重复回调幂等
|
|
||||||
- [ ] 各端上传 >10MB 图片自动压缩后可上传;仍超限有明确报错
|
|
||||||
- [ ] `pnpm lint` 无新增错误
|
|
||||||
Reference in New Issue
Block a user