Compare commits

...

11 Commits

Author SHA1 Message Date
jacy bdbd135318 merge(dev_jacy): mini-user logistics preview and detail timeline (v3.4.13) 2026-08-05 23:03:49 +08:00
jacy 8ca893efab feat(mini-user): show shipping logistics preview and detail timeline (v3.4.13)
Order detail displays latest XFX route under 配送中 with link to full timeline page; docs updated.

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 08:08:17 +08:00
jacy f80a0f2c27 Merge pull request '修改门店列表组件' (#12) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/12
2026-08-05 00:57:14 +08:00
jacy 528669f662 修改门店列表组件
CI / verify (pull_request) Has been cancelled
2026-08-05 00:55:58 +08:00
49 changed files with 1154 additions and 122 deletions
+1
View File
@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@ant-design/icons": "^5.5.1", "@ant-design/icons": "^5.5.1",
"@dukang/shared-types": "workspace:*", "@dukang/shared-types": "workspace:*",
"@dukang/shared-ui": "workspace:*",
"antd": "^5.22.0", "antd": "^5.22.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"echarts": "^6.1.0", "echarts": "^6.1.0",
@@ -15,8 +15,6 @@ type OssUploadProps = {
placeholder?: string; placeholder?: string;
}; };
const DEFAULT_MAX_MB = 10;
export default function OssUpload({ export default function OssUpload({
value, value,
onChange, onChange,
@@ -24,7 +22,6 @@ export default function OssUpload({
bizType, bizType,
mediaType = 'IMAGE', mediaType = 'IMAGE',
accept, accept,
maxSizeMb = DEFAULT_MAX_MB,
placeholder = '上传后自动填入,或手动粘贴 URL', placeholder = '上传后自动填入,或手动粘贴 URL',
}: OssUploadProps) { }: OssUploadProps) {
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
@@ -34,12 +31,6 @@ export default function OssUpload({
const customRequest: UploadProps['customRequest'] = async ({ file, onSuccess, onError }) => { const customRequest: UploadProps['customRequest'] = async ({ file, onSuccess, onError }) => {
const raw = file as File; const raw = file as File;
if (raw.size > maxSizeMb * 1024 * 1024) {
const err = new Error(`文件不能超过 ${maxSizeMb}MB`);
message.error(err.message);
onError?.(err);
return;
}
setUploading(true); setUploading(true);
try { try {
const result = await uploadFileToOss(raw, { bizType, mediaType }); const result = await uploadFileToOss(raw, { bizType, mediaType });
+16 -1
View File
@@ -1,4 +1,9 @@
import { apiBase, CLIENT_APP, getToken, clearAuth } from './api'; import { apiBase, CLIENT_APP, getToken, clearAuth } from './api';
import {
compressImageFileIfNeeded,
DEFAULT_OSS_MAX_UPLOAD_BYTES,
formatOssMaxSizeMb,
} from '@dukang/shared-ui/compressImage';
export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE'; export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE';
@@ -9,14 +14,24 @@ export type UploadFileResult = {
mock: boolean; mock: boolean;
}; };
async function prepareUploadFile(file: File, mediaType: OssMediaType): Promise<File> {
if (mediaType !== 'IMAGE') return file;
const compressed = await compressImageFileIfNeeded(file);
if (compressed.size > DEFAULT_OSS_MAX_UPLOAD_BYTES) {
throw new Error(`图片压缩后仍超过 ${formatOssMaxSizeMb()}MB,请换一张较小的图片`);
}
return compressed;
}
/** 经 API 服务端转存 OSS,避免浏览器直传跨域 */ /** 经 API 服务端转存 OSS,避免浏览器直传跨域 */
export async function uploadFileToOss( export async function uploadFileToOss(
file: File, file: File,
options: { bizType: string; mediaType?: OssMediaType }, options: { bizType: string; mediaType?: OssMediaType },
): Promise<UploadFileResult> { ): Promise<UploadFileResult> {
const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE'); const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE');
const prepared = await prepareUploadFile(file, mediaType);
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', prepared);
formData.append('bizType', options.bizType); formData.append('bizType', options.bizType);
formData.append('mediaType', mediaType); formData.append('mediaType', mediaType);
+30 -3
View File
@@ -16,10 +16,12 @@ import type { ColumnsType } from 'antd/es/table';
import { import {
DEV_PLAN_TASK_STATUS_LABELS, DEV_PLAN_TASK_STATUS_LABELS,
DEV_PLAN_TASK_TYPE_LABELS, DEV_PLAN_TASK_TYPE_LABELS,
SUPPORT_TICKET_STATUS_LABELS,
type DevPlanTaskDto, type DevPlanTaskDto,
type DevPlanTaskStatusDto, type DevPlanTaskStatusDto,
type DevPlanTaskTypeDto, type DevPlanTaskTypeDto,
type DevPlanVersionDto, type DevPlanVersionDto,
type SupportTicketDto,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
@@ -50,6 +52,7 @@ export default function DevPlanTasksPage() {
const [batchEditOpen, setBatchEditOpen] = useState(false); const [batchEditOpen, setBatchEditOpen] = useState(false);
const [batchSaving, setBatchSaving] = useState(false); const [batchSaving, setBatchSaving] = useState(false);
const [versions, setVersions] = useState<DevPlanVersionDto[]>([]); const [versions, setVersions] = useState<DevPlanVersionDto[]>([]);
const [supportTickets, setSupportTickets] = useState<SupportTicketDto[]>([]);
const [editing, setEditing] = useState<DevPlanTaskDto | null>(null); const [editing, setEditing] = useState<DevPlanTaskDto | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [dispatching, setDispatching] = useState(false); const [dispatching, setDispatching] = useState(false);
@@ -64,6 +67,13 @@ export default function DevPlanTasksPage() {
.catch(() => setVersions([])); .catch(() => setVersions([]));
}, [batchEditOpen]); }, [batchEditOpen]);
useEffect(() => {
if (!modalOpen || editing) return;
request<{ items: SupportTicketDto[] }>('/admin/support-tickets?pageSize=100')
.then((res) => setSupportTickets(res.items ?? []))
.catch(() => setSupportTickets([]));
}, [modalOpen, editing]);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<DevPlanTaskDto>( const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<DevPlanTaskDto>(
'/admin/dev-plan/tasks', '/admin/dev-plan/tasks',
() => { () => {
@@ -78,7 +88,7 @@ export default function DevPlanTasksPage() {
function openCreate() { function openCreate() {
setEditing(null); setEditing(null);
form.setFieldsValue({ content: '', type: 'BUG', status: 'TODO' }); form.setFieldsValue({ content: '', type: 'BUG', status: 'TODO', supportTicketId: undefined });
setModalOpen(true); setModalOpen(true);
} }
@@ -101,7 +111,11 @@ export default function DevPlanTasksPage() {
} else { } else {
await request('/admin/dev-plan/tasks', { await request('/admin/dev-plan/tasks', {
method: 'POST', method: 'POST',
body: JSON.stringify({ content: values.content, type: values.type }), body: JSON.stringify({
content: values.content,
type: values.type,
supportTicketId: values.supportTicketId || undefined,
}),
}); });
message.success('已创建'); message.success('已创建');
} }
@@ -305,7 +319,20 @@ export default function DevPlanTasksPage() {
<Form.Item name="status" label="状态" rules={[{ required: true }]}> <Form.Item name="status" label="状态" rules={[{ required: true }]}>
<Select options={STATUS_OPTIONS} /> <Select options={STATUS_OPTIONS} />
</Form.Item> </Form.Item>
) : null} ) : (
<Form.Item name="supportTicketId" label="绑定技术支持工单">
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="可选:关联工单"
options={supportTickets.map((t) => ({
value: t.id,
label: `${t.ticketNo} · ${SUPPORT_TICKET_STATUS_LABELS[t.status]} · ${t.title}`,
}))}
/>
</Form.Item>
)}
</Form> </Form>
</Modal> </Modal>
+33 -3
View File
@@ -13,11 +13,16 @@ type Row = {
settleAmount: number; settleAmount: number;
channel?: RedeemChannel; channel?: RedeemChannel;
createdAt: string; createdAt: string;
user?: { userNo: string; phone: string | null }; user?: { userNo: string; phone: string | null; nickname?: string | null };
store?: { name: string; cityName: string }; store?: { name: string; cityName: string };
coupon?: { couponNo: string }; coupon?: { couponNo: string };
}; };
function maskPhone(phone: string | null | undefined) {
if (!phone || phone.length < 7) return phone ?? '—';
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
}
export default function RedeemRecordsPage() { export default function RedeemRecordsPage() {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({}); const [filters, setFilters] = useState<Record<string, string>>({});
@@ -50,7 +55,19 @@ export default function RedeemRecordsPage() {
); );
}, },
}, },
{ title: '用户', dataIndex: ['user', 'userNo'], width: 110 }, { title: '用户编号', dataIndex: ['user', 'userNo'], width: 110 },
{
title: '用户昵称',
dataIndex: ['user', 'nickname'],
width: 100,
render: (v: string | null | undefined) => v || '—',
},
{
title: '用户手机',
dataIndex: ['user', 'phone'],
width: 120,
render: (v: string | null | undefined) => maskPhone(v),
},
{ title: '门店', dataIndex: ['store', 'name'] }, { title: '门店', dataIndex: ['store', 'name'] },
{ title: '核销额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` }, { title: '核销额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` },
{ title: '结算额', dataIndex: 'settleAmount', width: 90, render: (v) => `¥${v}` }, { title: '结算额', dataIndex: 'settleAmount', width: 90, render: (v) => `¥${v}` },
@@ -113,7 +130,7 @@ export default function RedeemRecordsPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1100 }} scroll={{ x: 1300 }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
@@ -138,6 +155,19 @@ 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,6 +30,7 @@ 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,
@@ -37,6 +38,7 @@ 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';
@@ -64,6 +66,17 @@ const STATUS_OPTIONS = (Object.keys(SUPPORT_TICKET_STATUS_LABELS) as SupportTick
(value) => ({ value, label: SUPPORT_TICKET_STATUS_LABELS[value] }), (value) => ({ value, label: SUPPORT_TICKET_STATUS_LABELS[value] }),
); );
const PRIORITY_COLOR: Record<SupportTicketPriorityDto, string> = {
LOW: 'default',
NORMAL: 'blue',
HIGH: 'orange',
URGENT: 'red',
};
const PRIORITY_OPTIONS = (Object.keys(SUPPORT_TICKET_PRIORITY_LABELS) as SupportTicketPriorityDto[]).map(
(value) => ({ value, label: SUPPORT_TICKET_PRIORITY_LABELS[value] }),
);
const TASK_TYPE_OPTIONS = (Object.keys(DEV_PLAN_TASK_TYPE_LABELS) as DevPlanTaskTypeDto[]).map( const TASK_TYPE_OPTIONS = (Object.keys(DEV_PLAN_TASK_TYPE_LABELS) as DevPlanTaskTypeDto[]).map(
(v) => ({ value: v, label: DEV_PLAN_TASK_TYPE_LABELS[v] }), (v) => ({ value: v, label: DEV_PLAN_TASK_TYPE_LABELS[v] }),
); );
@@ -80,6 +93,7 @@ export default function SupportTicketsPage() {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (filters.ticketType) qs.set('ticketType', filters.ticketType); if (filters.ticketType) qs.set('ticketType', filters.ticketType);
if (filters.status) qs.set('status', filters.status); if (filters.status) qs.set('status', filters.status);
if (filters.priority) qs.set('priority', filters.priority);
return qs; return qs;
}, },
[filters], [filters],
@@ -154,6 +168,7 @@ export default function SupportTicketsPage() {
title: values.title.trim(), title: values.title.trim(),
content: values.content?.trim() || undefined, content: values.content?.trim() || undefined,
remark: values.remark?.trim() || undefined, remark: values.remark?.trim() || undefined,
priority: values.priority,
attachmentUrls: (values.attachmentUrls ?? []).map((u: string) => u?.trim()).filter(Boolean), attachmentUrls: (values.attachmentUrls ?? []).map((u: string) => u?.trim()).filter(Boolean),
}), }),
}); });
@@ -217,6 +232,7 @@ export default function SupportTicketsPage() {
title: detail.title, title: detail.title,
content: detail.content || '', content: detail.content || '',
remark: detail.remark || '', remark: detail.remark || '',
priority: detail.priority ?? 'NORMAL',
attachmentUrls: detail.attachmentUrls?.length ? detail.attachmentUrls : [''], attachmentUrls: detail.attachmentUrls?.length ? detail.attachmentUrls : [''],
}); });
setEditOpen(true); setEditOpen(true);
@@ -233,6 +249,7 @@ export default function SupportTicketsPage() {
title: values.title.trim(), title: values.title.trim(),
content: values.content?.trim() || undefined, content: values.content?.trim() || undefined,
remark: values.remark?.trim() || undefined, remark: values.remark?.trim() || undefined,
priority: values.priority,
attachmentUrls: (values.attachmentUrls ?? []).map((u: string) => u?.trim()).filter(Boolean), attachmentUrls: (values.attachmentUrls ?? []).map((u: string) => u?.trim()).filter(Boolean),
}), }),
}); });
@@ -451,6 +468,16 @@ 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',
@@ -562,6 +589,9 @@ export default function SupportTicketsPage() {
<Form.Item name="status" label="状态"> <Form.Item name="status" label="状态">
<Select allowClear style={{ width: 120 }} options={STATUS_OPTIONS} /> <Select allowClear style={{ width: 120 }} options={STATUS_OPTIONS} />
</Form.Item> </Form.Item>
<Form.Item name="priority" label="优先级">
<Select allowClear style={{ width: 120 }} options={PRIORITY_OPTIONS} />
</Form.Item>
<Button type="primary" htmlType="submit"> <Button type="primary" htmlType="submit">
</Button> </Button>
@@ -577,11 +607,7 @@ export default function SupportTicketsPage() {
isSuperAdmin isSuperAdmin
? { ? {
selectedRowKeys, selectedRowKeys,
onChange: (keys, rows) => { onChange: (keys) => setSelectedRowKeys(keys as string[]),
const pending = rows.filter((r) => r.status === 'PENDING_REVIEW').map((r) => String(r.id));
setSelectedRowKeys(pending.length === rows.length ? (keys as string[]) : pending);
},
getCheckboxProps: (row) => ({ disabled: row.status !== 'PENDING_REVIEW' }),
} }
: undefined : undefined
} }
@@ -607,6 +633,9 @@ export default function SupportTicketsPage() {
{detail.ticketNo} {detail.ticketNo}
</Typography.Text> </Typography.Text>
<Tag>{SUPPORT_TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType}</Tag> <Tag>{SUPPORT_TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType}</Tag>
<Tag color={PRIORITY_COLOR[detail.priority ?? 'NORMAL']}>
{SUPPORT_TICKET_PRIORITY_LABELS[detail.priority ?? 'NORMAL']}
</Tag>
</> </>
)} )}
</Space> </Space>
@@ -865,10 +894,13 @@ export default function SupportTicketsPage() {
</Drawer> </Drawer>
<Modal title="创建技术支持工单" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={() => void submitCreate()} confirmLoading={creating} destroyOnClose okText="提交"> <Modal title="创建技术支持工单" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={() => void submitCreate()} confirmLoading={creating} destroyOnClose okText="提交">
<Form form={createForm} layout="vertical" initialValues={{ ticketType: 'BUG', attachmentUrls: [''] }}> <Form form={createForm} layout="vertical" initialValues={{ ticketType: 'BUG', priority: 'NORMAL', attachmentUrls: [''] }}>
<Form.Item name="ticketType" label="类型" rules={[{ required: true }]}> <Form.Item name="ticketType" label="类型" rules={[{ required: true }]}>
<Select options={TYPE_OPTIONS} /> <Select options={TYPE_OPTIONS} />
</Form.Item> </Form.Item>
<Form.Item name="priority" label="优先级" rules={[{ required: true }]}>
<Select options={PRIORITY_OPTIONS} />
</Form.Item>
<Form.Item name="title" label="标题" rules={[{ required: true }]}> <Form.Item name="title" label="标题" rules={[{ required: true }]}>
<Input maxLength={128} showCount /> <Input maxLength={128} showCount />
</Form.Item> </Form.Item>
@@ -972,6 +1004,9 @@ export default function SupportTicketsPage() {
<Form.Item name="ticketType" label="类型" rules={[{ required: true }]}> <Form.Item name="ticketType" label="类型" rules={[{ required: true }]}>
<Select options={TYPE_OPTIONS} /> <Select options={TYPE_OPTIONS} />
</Form.Item> </Form.Item>
<Form.Item name="priority" label="优先级" rules={[{ required: true }]}>
<Select options={PRIORITY_OPTIONS} />
</Form.Item>
<Form.Item name="title" label="标题" rules={[{ required: true }]}> <Form.Item name="title" label="标题" rules={[{ required: true }]}>
<Input maxLength={128} showCount /> <Input maxLength={128} showCount />
</Form.Item> </Form.Item>
+1 -1
View File
@@ -258,7 +258,7 @@ export default function WineryBillsPage() {
</Typography.Title> </Typography.Title>
<Typography.Text type="secondary"> <Typography.Text type="secondary">
8:00 × {ratePct}%绿 T+3 8:00 3 / × {ratePct}%绿
</Typography.Text> </Typography.Text>
</Space> </Space>
{canEditWineryBank ? ( {canEditWineryBank ? (
@@ -212,6 +212,14 @@ export default function PromoCodeDetailPage() {
<Statistic title="扫码进入次数" value={stats?.scanCount ?? detail.scanCount} /> <Statistic title="扫码进入次数" value={stats?.scanCount ?? detail.scanCount} />
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="归因用户数"
value={stats?.attributionCount ?? 0}
/>
</Card>
</Col>
<Col xs={12} sm={6}> <Col xs={12} sm={6}>
<Card size="small"> <Card size="small">
<Statistic <Statistic
@@ -19,8 +19,6 @@ type OssUploadFieldProps = {
onWechatReadyChange?: (ready: boolean) => void; onWechatReadyChange?: (ready: boolean) => void;
}; };
const DEFAULT_MAX_MB = 10;
function isCancelError(msg: string): boolean { function isCancelError(msg: string): boolean {
return /cancel|取消/i.test(msg); return /cancel|取消/i.test(msg);
} }
@@ -79,10 +77,6 @@ export default function OssUploadField({
showUploadError('图片读取失败,请重新选择'); showUploadError('图片读取失败,请重新选择');
return; return;
} }
if (file.size > DEFAULT_MAX_MB * 1024 * 1024) {
showUploadError(`文件不能超过 ${DEFAULT_MAX_MB}MB`);
return;
}
const result = await uploadFileToOss(file, { bizType, mediaType }); const result = await uploadFileToOss(file, { bizType, mediaType });
onChange?.(result.url); onChange?.(result.url);
} }
+16 -1
View File
@@ -1,4 +1,9 @@
import { apiBase, request } from './api'; import { apiBase, request } from './api';
import {
compressImageFileIfNeeded,
DEFAULT_OSS_MAX_UPLOAD_BYTES,
formatOssMaxSizeMb,
} from '@dukang/shared-ui/compressImage';
const UPLOAD_TIMEOUT_MS = 120_000; const UPLOAD_TIMEOUT_MS = 120_000;
@@ -11,13 +16,23 @@ export type UploadFileResult = {
mock: boolean; mock: boolean;
}; };
async function prepareUploadFile(file: File, mediaType: OssMediaType): Promise<File> {
if (mediaType !== 'IMAGE') return file;
const compressed = await compressImageFileIfNeeded(file);
if (compressed.size > DEFAULT_OSS_MAX_UPLOAD_BYTES) {
throw new Error(`图片压缩后仍超过 ${formatOssMaxSizeMb()}MB,请换一张较小的图片`);
}
return compressed;
}
async function uploadFileToOssInner( async function uploadFileToOssInner(
file: File, file: File,
options: { bizType: string; mediaType?: OssMediaType }, options: { bizType: string; mediaType?: OssMediaType },
): Promise<UploadFileResult> { ): Promise<UploadFileResult> {
const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE'); const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE');
const prepared = await prepareUploadFile(file, mediaType);
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', prepared);
formData.append('bizType', options.bizType); formData.append('bizType', options.bizType);
formData.append('mediaType', mediaType); formData.append('mediaType', mediaType);
+11 -1
View File
@@ -168,12 +168,22 @@ export default function LoginPage() {
async function login() { async function login() {
if (!ensureAgreed()) return; if (!ensureAgreed()) return;
const trimmedPhone = phone.trim();
const trimmedCode = code.trim();
if (!trimmedPhone) {
setMsg('phone should not be empty');
return;
}
if (!trimmedCode) {
setMsg('code should not be empty');
return;
}
setLoading(true); setLoading(true);
setMsg(''); setMsg('');
try { try {
const data = await request<PartnerSessionPayload>('PARTNER_H5', '/partner/auth/login/sms', { const data = await request<PartnerSessionPayload>('PARTNER_H5', '/partner/auth/login/sms', {
method: 'POST', method: 'POST',
body: JSON.stringify({ phone, code }), body: JSON.stringify({ phone: trimmedPhone, code: trimmedCode }),
silent: true, silent: true,
}); });
saveRememberedSession(data); saveRememberedSession(data);
+10 -1
View File
@@ -1,5 +1,10 @@
import { apiBase } from './api'; import { apiBase } from './api';
import { getStoreProfile } from './api'; import { getStoreProfile } from './api';
import {
compressImageFileIfNeeded,
DEFAULT_OSS_MAX_UPLOAD_BYTES,
formatOssMaxSizeMb,
} from '@dukang/shared-ui/compressImage';
const UPLOAD_TIMEOUT_MS = 120_000; const UPLOAD_TIMEOUT_MS = 120_000;
@@ -16,8 +21,12 @@ export type RegisteredResource = {
}; };
export async function uploadFileToOss(file: File, bizType: string): Promise<UploadFileResult> { export async function uploadFileToOss(file: File, bizType: string): Promise<UploadFileResult> {
const compressed = await compressImageFileIfNeeded(file);
if (compressed.size > DEFAULT_OSS_MAX_UPLOAD_BYTES) {
throw new Error(`图片压缩后仍超过 ${formatOssMaxSizeMb()}MB,请换一张较小的图片`);
}
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', compressed);
formData.append('bizType', bizType); formData.append('bizType', bizType);
formData.append('mediaType', 'IMAGE'); formData.append('mediaType', 'IMAGE');
+11 -1
View File
@@ -140,12 +140,22 @@ export default function LoginPage() {
async function login() { async function login() {
if (!ensureAgreed()) return; if (!ensureAgreed()) return;
const trimmedPhone = phone.trim();
const trimmedCode = code.trim();
if (!trimmedPhone) {
setMsg('phone should not be empty');
return;
}
if (!trimmedCode) {
setMsg('code should not be empty');
return;
}
setLoading(true); setLoading(true);
setMsg(''); setMsg('');
try { try {
const data = await request<ShopSessionPayload>('SHOP_H5', '/shop/auth/login/sms', { const data = await request<ShopSessionPayload>('SHOP_H5', '/shop/auth/login/sms', {
method: 'POST', method: 'POST',
body: JSON.stringify({ phone, code }), body: JSON.stringify({ phone: trimmedPhone, code: trimmedCode }),
}); });
saveRememberedSession(data); saveRememberedSession(data);
applySession(data); applySession(data);
+13 -1
View File
@@ -1,4 +1,9 @@
import { apiBase } from './api'; import { apiBase } from './api';
import {
compressImageFileIfNeeded,
DEFAULT_OSS_MAX_UPLOAD_BYTES,
formatOssMaxSizeMb,
} from '@dukang/shared-ui/compressImage';
export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE'; export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE';
@@ -15,8 +20,15 @@ export async function uploadFileToOss(
options: { bizType: string; mediaType?: OssMediaType }, options: { bizType: string; mediaType?: OssMediaType },
): Promise<UploadFileResult> { ): Promise<UploadFileResult> {
const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE'); const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE');
let prepared = file;
if (mediaType === 'IMAGE') {
prepared = await compressImageFileIfNeeded(file);
if (prepared.size > DEFAULT_OSS_MAX_UPLOAD_BYTES) {
throw new Error(`图片压缩后仍超过 ${formatOssMaxSizeMb()}MB,请换一张较小的图片`);
}
}
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', prepared);
formData.append('bizType', options.bizType); formData.append('bizType', options.bizType);
formData.append('mediaType', mediaType); formData.append('mediaType', mediaType);
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@dukang/mini-user", "name": "@dukang/mini-user",
"version": "0.1.0", "version": "3.4.13",
"private": true, "private": true,
"description": "杜康好客 · C 端用户微信小程序(Taro)", "description": "杜康好客 · C 端用户微信小程序(Taro)",
"scripts": { "scripts": {
+1
View File
@@ -11,6 +11,7 @@ export default defineAppConfig({
'pages/pay/index', 'pages/pay/index',
'pages/orders/index', 'pages/orders/index',
'pages/order-detail/index', 'pages/order-detail/index',
'pages/order-logistics/index',
'pages/pickup-receive/index', 'pages/pickup-receive/index',
'pages/addresses/index', 'pages/addresses/index',
'pages/address-edit/index', 'pages/address-edit/index',
@@ -20,6 +20,7 @@ export default function ProductCarousel({
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const prefix = const prefix =
variant === 'store' ? 'store-detail-carousel' : variant === 'home' ? 'home-carousel' : 'detail-carousel'; variant === 'store' ? 'store-detail-carousel' : variant === 'home' ? 'home-carousel' : 'detail-carousel';
const imageMode = variant === 'store' ? 'aspectFit' : 'aspectFill';
function previewAt(index: number) { function previewAt(index: number) {
const urls = slides.filter(Boolean); const urls = slides.filter(Boolean);
@@ -41,7 +42,7 @@ export default function ProductCarousel({
<Image <Image
className={`${prefix}-image`} className={`${prefix}-image`}
src={src} src={src}
mode="aspectFill" mode={imageMode}
alt={alt} alt={alt}
onClick={previewable ? () => previewAt(index) : undefined} onClick={previewable ? () => previewAt(index) : undefined}
/> />
@@ -4,6 +4,7 @@ import { useEffect, useRef } from 'react';
import { finishLoginNavigate, forceReloadAfterAccountMerge, goLogin } from '../lib/auth-nav'; import { finishLoginNavigate, forceReloadAfterAccountMerge, goLogin } from '../lib/auth-nav';
import { toast } from '../lib/api'; import { toast } from '../lib/api';
import { capturePromoSceneAndTouchScan } from '../lib/promo'; import { capturePromoSceneAndTouchScan } from '../lib/promo';
import { initClientVersionChecks } from '../lib/client-version';
import { saveWechatLoginResult } from '../lib/pay-wechat'; import { saveWechatLoginResult } from '../lib/pay-wechat';
import { applyWechatShare } from '../lib/wechat-share'; import { applyWechatShare } from '../lib/wechat-share';
import { handleWechatAuthCallback } from '../lib/wechat-auth'; import { handleWechatAuthCallback } from '../lib/wechat-auth';
@@ -38,6 +39,7 @@ export default function WechatShareBootstrap() {
useEffect(() => { useEffect(() => {
void capturePromoSceneAndTouchScan(); void capturePromoSceneAndTouchScan();
initClientVersionChecks();
}, []); }, []);
useEffect(() => { useEffect(() => {
+70
View File
@@ -0,0 +1,70 @@
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();
}
+34
View File
@@ -0,0 +1,34 @@
import Taro from '@tarojs/taro';
const MAX_BYTES = 10 * 1024 * 1024;
/** 小程序临时文件超过 10MB 时用 compressImage 压缩后再上传 */
export async function compressWeappImageIfNeeded(tempFilePath: string): Promise<string> {
if (process.env.TARO_ENV !== 'weapp') return tempFilePath;
let path = tempFilePath;
let size = await getFileSize(path);
if (size <= MAX_BYTES) return path;
const qualities = [80, 65, 50, 40];
for (const quality of qualities) {
const res = await Taro.compressImage({ src: path, quality });
path = res.tempFilePath;
size = await getFileSize(path);
if (size <= MAX_BYTES) return path;
}
if (size > MAX_BYTES) {
throw new Error('图片压缩后仍超过 10MB,请换一张较小的图片');
}
return path;
}
async function getFileSize(filePath: string): Promise<number> {
try {
const info = await Taro.getFileInfo({ filePath });
return typeof info.size === 'number' ? info.size : 0;
} catch {
return 0;
}
}
@@ -69,12 +69,15 @@ export function mergeWxDisplayProfile(profile: UserProfile): UserProfile {
/** 上传 chooseAvatar 临时文件到 OSS,并返回已登记到当前用户的真实资源。 */ /** 上传 chooseAvatar 临时文件到 OSS,并返回已登记到当前用户的真实资源。 */
export async function uploadAvatarTempFile(tempFilePath: string): Promise<UploadedAvatarResource> { export async function uploadAvatarTempFile(tempFilePath: string): Promise<UploadedAvatarResource> {
const { API_BASE, CLIENT_APP, getToken } = await import('./api'); const { API_BASE, CLIENT_APP, getToken } = await import('./api');
const { compressWeappImageIfNeeded } = await import('./compress-image');
const token = getToken(); const token = getToken();
if (!token) throw new Error('请先登录'); if (!token) throw new Error('请先登录');
const filePath = await compressWeappImageIfNeeded(tempFilePath);
const res = await Taro.uploadFile({ const res = await Taro.uploadFile({
url: `${API_BASE}/common/resources/upload`, url: `${API_BASE}/common/resources/upload`,
filePath: tempFilePath, filePath,
name: 'file', name: 'file',
formData: { formData: {
bizType: 'AVATAR', bizType: 'AVATAR',
+56
View File
@@ -0,0 +1,56 @@
import { isXfxProviderCode } from '@dukang/shared-types';
import { formatShanghaiDateTime } from './datetime';
import { request } from './api';
export type OrderDelivery = {
provider?: string;
trackingNo?: string;
logisticsCompany?: string;
manualQueryUrl?: string;
};
export type OrderTrackNode = {
trackInfo?: string;
createTime?: string;
createdAt?: string;
statusName?: string;
};
export type OrderTrackResponse = {
nodes?: OrderTrackNode[];
manualQueryUrl?: string | null;
provider?: string;
trackingNo?: string;
logisticsCompany?: string;
};
/** 订单详情页展示物流摘要的状态(配送中) */
export const ORDER_LOGISTICS_PREVIEW_STATUSES = ['SHIPPING', 'SHIPPED'] as const;
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 latestTrackNode(nodes: OrderTrackNode[]) {
if (nodes.length === 0) return null;
return nodes[0];
}
export async function fetchOrderTrack(orderId: string) {
return request<OrderTrackResponse>(`/trade/orders/${orderId}/track`);
}
@@ -141,6 +141,14 @@ export default function OrderConfirmPickupPage() {
const ready = await ensurePayReady(returnPath); const ready = await ensurePayReady(returnPath);
if (!ready) return; if (!ready) return;
const { confirm } = await Taro.showModal({
title: '确认提交订单',
content: `确认提交现场提货订单?共 ${quantity} 瓶,应付 ¥${Number(preview?.payAmount ?? 0).toFixed(2)}`,
confirmText: '确认提交',
cancelText: '再想想',
});
if (!confirm) return;
setLoading(true); setLoading(true);
setMsg(''); setMsg('');
try { try {
@@ -8,6 +8,13 @@ import WechatShareReady from '../../components/WechatShareReady';
import ContactCsButton from '../../components/ContactCsButton'; import ContactCsButton from '../../components/ContactCsButton';
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 {
fetchOrderTrack,
latestTrackNode,
ORDER_LOGISTICS_PREVIEW_STATUSES,
shouldLoadOrderTrack,
type OrderTrackNode,
} from '../../lib/order-logistics';
import { maskPhone } from '../../lib/phone'; import { maskPhone } from '../../lib/phone';
import { confirmOrderReceive, type WechatConfirmPayload } from '../../lib/wechat-order-confirm'; import { confirmOrderReceive, type WechatConfirmPayload } from '../../lib/wechat-order-confirm';
import { import {
@@ -44,8 +51,15 @@ type OrderDetail = {
orderType?: string; orderType?: string;
isProxyOrder?: boolean; isProxyOrder?: boolean;
proxyPartnerName?: string | null; proxyPartnerName?: string | null;
deliveryType?: string;
items?: OrderItem[]; items?: OrderItem[];
wechatConfirm?: WechatConfirmPayload | null; wechatConfirm?: WechatConfirmPayload | null;
delivery?: {
provider?: string;
trackingNo?: string;
logisticsCompany?: string;
manualQueryUrl?: string;
} | null;
}; };
const STATUS_LABELS: Record<string, string> = { const STATUS_LABELS: Record<string, string> = {
@@ -78,11 +92,32 @@ 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 [trackLoading, setTrackLoading] = useState(false);
async function loadOrderTrack(delivery: OrderDetail['delivery']) {
if (!orderId || !shouldLoadOrderTrack(delivery)) {
setLatestTrack(null);
return;
}
setTrackLoading(true);
try {
const track = await fetchOrderTrack(orderId);
setLatestTrack(latestTrackNode(track.nodes ?? []));
} catch {
setLatestTrack(null);
} finally {
setTrackLoading(false);
}
}
useEffect(() => { useEffect(() => {
if (!orderId) return; if (!orderId) return;
request<OrderDetail>(`/trade/orders/${orderId}`) request<OrderDetail>(`/trade/orders/${orderId}`)
.then(setOrder) .then((data) => {
setOrder(data);
void loadOrderTrack(data.delivery);
})
.catch((e) => toast(e instanceof Error ? e.message : '加载失败')); .catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
}, [orderId]); }, [orderId]);
@@ -90,7 +125,10 @@ export default function OrderDetailPage() {
if (!orderId) return; if (!orderId) return;
// 从微信确认收货组件返回后刷新 // 从微信确认收货组件返回后刷新
request<OrderDetail>(`/trade/orders/${orderId}`) request<OrderDetail>(`/trade/orders/${orderId}`)
.then(setOrder) .then((data) => {
setOrder(data);
void loadOrderTrack(data.delivery);
})
.catch(() => {}); .catch(() => {});
}); });
@@ -99,6 +137,18 @@ export default function OrderDetailPage() {
const canPay = !!order && order.status === 'PENDING_PAY' && !isReship; const canPay = !!order && order.status === 'PENDING_PAY' && !isReship;
const canConfirmReceive = const canConfirmReceive =
!!order && !isReship && !isProxy && ['PENDING_RECEIVE', 'DELIVERED'].includes(order.status || ''); !!order && !isReship && !isProxy && ['PENDING_RECEIVE', 'DELIVERED'].includes(order.status || '');
const canViewLogistics =
!!order &&
order.deliveryType !== 'ON_SITE_PICKUP' &&
!isReship &&
['PENDING_SHIP', 'OUT_WAREHOUSE', 'SHIPPING', 'SHIPPED', 'PENDING_RECEIVE', 'DELIVERED', 'COMPLETED'].includes(
order.status || '',
);
const showLogisticsPreview =
canViewLogistics &&
ORDER_LOGISTICS_PREVIEW_STATUSES.includes(
(order?.status || '') as (typeof ORDER_LOGISTICS_PREVIEW_STATUSES)[number],
);
const item = order?.items?.[0]; const item = order?.items?.[0];
const productName = item?.productName || order?.productName || '杜康商品'; const productName = item?.productName || order?.productName || '杜康商品';
@@ -134,6 +184,11 @@ export default function OrderDetailPage() {
Taro.navigateTo({ url: '/pages/customer-service/index' }); Taro.navigateTo({ url: '/pages/customer-service/index' });
} }
function goLogistics() {
if (!order) return;
Taro.navigateTo({ url: `/pages/order-logistics/index?id=${order.id}` });
}
async function confirmReceive() { async function confirmReceive() {
if (!order || !canConfirmReceive || confirming) return; if (!order || !canConfirmReceive || confirming) return;
@@ -211,6 +266,26 @@ 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" onClick={goLogistics}>
<View className="order-logistics-preview-main">
{trackLoading ? (
<Text className="order-logistics-preview-info u-muted"></Text>
) : latestTrack?.trackInfo ? (
<Text className="order-logistics-preview-info">{latestTrack.trackInfo}</Text>
) : 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>
) : canViewLogistics ? (
<Text className="order-logistics-link" onClick={goLogistics}>
</Text>
) : null}
</View> </View>
<View className="order-card"> <View className="order-card">
<Text className="order-card-title"></Text> <Text className="order-card-title"></Text>
@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '物流追踪',
});
@@ -0,0 +1,168 @@
import { useEffect, useState } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import { request, toast } from '../../lib/api';
import {
deliveryProviderLabel,
fetchOrderTrack,
formatTrackNodeTime,
shouldLoadOrderTrack,
type OrderDelivery,
type OrderTrackNode,
} from '../../lib/order-logistics';
import { usePageView } from '../../lib/usePageView';
type OrderDetail = {
id: string;
orderNo?: string;
status?: string;
delivery?: OrderDelivery | null;
};
const STATUS_LABELS: Record<string, string> = {
PENDING_SHIP: '待发货',
OUT_WAREHOUSE: '出库中',
SHIPPING: '配送中',
SHIPPED: '配送中',
PENDING_RECEIVE: '待签收',
DELIVERED: '待签收',
COMPLETED: '已完成',
};
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 [trackLoading, setTrackLoading] = useState(false);
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([]);
return;
}
setTrackLoading(true);
try {
const track = await fetchOrderTrack(orderId);
if (!cancelled) setTrackNodes(track.nodes ?? []);
} catch {
if (!cancelled) setTrackNodes([]);
} 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('无法打开物流查询'));
}
const delivery = order?.delivery;
return (
<PageShell variant="sub" className="order-logistics-page">
<SubPageHeader title="物流详情" />
<View className="sub-page-body">
{!order ? (
<View className="u-empty"></View>
) : (
<>
<View className="order-card">
<Text className="order-card-title"></Text>
<Text className="order-list-status">
{STATUS_LABELS[order.status || ''] || order.status || '处理中'}
</Text>
</View>
<View className="order-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>
{trackLoading ? (
<View className="order-card">
<Text className="order-card-title"></Text>
<Text className="u-muted"></Text>
</View>
) : trackNodes.length > 0 ? (
<View className="order-card">
<Text className="order-card-title"></Text>
<View className="order-logistics-timeline">
{trackNodes.map((node, index) => (
<View
key={`${node.trackInfo}-${node.createTime}-${index}`}
className={`order-logistics-timeline-item${index === 0 ? ' order-logistics-timeline-item--latest' : ''}`}
>
<View className="order-logistics-timeline-dot" />
<View className="order-logistics-timeline-body">
{node.statusName ? (
<Text className="order-logistics-timeline-status">{node.statusName}</Text>
) : null}
<Text className="order-logistics-node-info">{node.trackInfo || '—'}</Text>
<Text className="order-logistics-node-time">{formatTrackNodeTime(node)}</Text>
</View>
</View>
))}
</View>
</View>
) : shouldLoadOrderTrack(delivery) ? (
<View className="order-card">
<Text className="order-card-title"></Text>
<Text className="u-muted"></Text>
</View>
) : null}
</>
)}
</View>
</PageShell>
);
}
@@ -11,7 +11,6 @@ import type { ProductDetailContentDto } from '@dukang/shared-types';
import PageShell from '../../components/PageShell'; import PageShell from '../../components/PageShell';
import PageNavBar from '../../components/PageNavBar'; import PageNavBar from '../../components/PageNavBar';
import ProductCarousel from '../../components/ProductCarousel'; import ProductCarousel from '../../components/ProductCarousel';
import ShareNavButton from '../../components/ShareNavButton';
import WechatShareReady from '../../components/WechatShareReady'; import WechatShareReady from '../../components/WechatShareReady';
import { goLogin } from '../../lib/auth-nav'; import { goLogin } from '../../lib/auth-nav';
import { ensurePayReady } from '../../lib/pay-ready'; import { ensurePayReady } from '../../lib/pay-ready';
@@ -167,12 +166,11 @@ export default function ProductDetailPage() {
solid={headerSolid} solid={headerSolid}
titleVisible={headerSolid} titleVisible={headerSolid}
onBack={goBack} onBack={goBack}
right={<ShareNavButton payload={sharePayload} />}
/> />
<View className="product-detail-main"> <View className="product-detail-main">
<View className="product-detail-hero full-bleed"> <View className="product-detail-hero full-bleed">
<ProductCarousel images={carouselImages} alt={product.name} variant="detail" /> <ProductCarousel images={carouselImages} alt={product.name} variant="detail" previewable />
</View> </View>
<View className="product-detail-info"> <View className="product-detail-info">
@@ -15,6 +15,8 @@ import ShareNavButton from '../../components/ShareNavButton';
import StoreRedeemMarquee from '../../components/StoreRedeemMarquee'; import StoreRedeemMarquee from '../../components/StoreRedeemMarquee';
import WechatShareReady from '../../components/WechatShareReady'; import WechatShareReady from '../../components/WechatShareReady';
import { request, toast } from '../../lib/api'; import { request, toast } from '../../lib/api';
import { maskPhone } from '../../lib/phone';
import { track } from '../../lib/analytics';
import { formatShanghaiDateTime } from '../../lib/datetime'; import { formatShanghaiDateTime } from '../../lib/datetime';
import { import {
DEFAULT_SHARE_DESC, DEFAULT_SHARE_DESC,
@@ -154,6 +156,7 @@ 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;
@@ -271,6 +274,7 @@ export default function StoreDetailPage() {
toast('暂无门店电话'); toast('暂无门店电话');
return; return;
} }
track('store_phone_call', { storeId: store.id });
Taro.makePhoneCall({ phoneNumber: store.phone }).catch(() => toast('无法拨打电话')); Taro.makePhoneCall({ phoneNumber: store.phone }).catch(() => toast('无法拨打电话'));
} }
@@ -374,7 +378,9 @@ export default function StoreDetailPage() {
{store.phone ? ( {store.phone ? (
<View className="store-detail-row"> <View className="store-detail-row">
<Text className="store-detail-meta store-detail-meta--flex">: {store.phone}</Text> <Text className="store-detail-meta store-detail-meta--flex">
: {maskPhone(store.phone)}
</Text>
<Text className="store-detail-action" onClick={callStore}> <Text className="store-detail-action" onClick={callStore}>
</Text> </Text>
@@ -399,15 +405,32 @@ 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) => {
<View key={`${pkg.name}-${index}`} className="store-detail-package-card"> const expanded = !!expandedPackages[index];
const bodyText = `${formatRedeemAmountYuan(pkg.price)} 元 · ${pkg.dishes}`;
const longBody = bodyText.length > 48 || (pkg.otherNotes?.length ?? 0) > 40;
return (
<View
key={`${pkg.name}-${index}`}
className={`store-detail-package-card${expanded || !longBody ? '' : ' store-detail-package-card--collapsed'}`}
>
{pkg.imageUrl ? ( {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}
<Text className="store-detail-package-name">{pkg.name}</Text> <View className="store-detail-package-head">
<Text className="store-detail-package-body"> <Text className="store-detail-package-name">{pkg.name}</Text>
{formatRedeemAmountYuan(pkg.price)} · {pkg.dishes} {longBody ? (
</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}
@@ -415,7 +438,8 @@ 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}
+23 -16
View File
@@ -343,10 +343,11 @@ export default function StoresPage() {
} }
function formatHours(store: Store) { function formatHours(store: Store) {
const parts: string[] = []; // 列表只展示第一段营业时间,避免挤占一行
if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`); if (store.openTime && store.closeTime) {
if (store.openTime2 && store.closeTime2) parts.push(`${store.openTime2}-${store.closeTime2}`); return `营业时间: ${store.openTime}-${store.closeTime}`;
return parts.length ? `营业时间: ${parts.join('')}` : '营业时间: 10:00-22:00'; }
return '营业时间: 10:00-22:00';
} }
function formatStatus(store: Store) { function formatStatus(store: Store) {
@@ -437,30 +438,36 @@ export default function StoresPage() {
<View className="store-card-cover store-card-cover--empty" /> <View className="store-card-cover store-card-cover--empty" />
)} )}
<View className="store-card-body"> <View className="store-card-body">
<View className="store-card-head"> {/* 第1行:标题 + 距离 */}
<Text className="store-card-name">{s.name}</Text> <View className="store-card-row store-card-row--head">
<Text className="store-card-name" numberOfLines={1}>
{s.name}
</Text>
<Text className="store-card-distance"> <Text className="store-card-distance">
{formatDistanceMeters(s.distanceMeters)} {formatDistanceMeters(s.distanceMeters)}
</Text> </Text>
</View> </View>
<View className="store-card-status-row"> {/* 第2行:状态 + 营业时间(仅第一段) */}
<View className="store-card-row store-card-row--meta">
<Text className="store-card-status">{formatStatus(s)}</Text> <Text className="store-card-status">{formatStatus(s)}</Text>
<Text className="store-card-hours">{formatHours(s)}</Text> <Text className="store-card-hours" numberOfLines={1}>
{formatHours(s)}
</Text>
</View> </View>
<Text className="store-card-address"> {/* 第3行:地址 + 去核销 */}
{s.address || (s.district ? `${s.district}` : '地址待完善')} <View className="store-card-row store-card-row--foot">
</Text> <Text className="store-card-address" numberOfLines={1}>
<View className="store-card-footer"> {s.address || (s.district ? `${s.district}` : '地址待完善')}
<View className="store-card-footer-spacer" /> </Text>
<Text <View
className="store-card-cta" className="store-card-cta"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
Taro.navigateTo({ url: '/pages/redeem/index' }); Taro.navigateTo({ url: '/pages/redeem/index' });
}} }}
> >
<Text className="store-card-cta-text"></Text>
</Text> </View>
</View> </View>
</View> </View>
</View> </View>
+130
View File
@@ -488,3 +488,133 @@
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-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-row-value--link {
color: var(--color-heritage-red);
}
.order-logistics-timeline {
padding-top: 4px;
}
.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-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 1px var(--color-heritage-red);
}
.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--latest .order-logistics-timeline-status {
color: var(--color-heritage-red);
}
.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;
}
+25 -2
View File
@@ -10,13 +10,15 @@
.store-detail-carousel-wrap { .store-detail-carousel-wrap {
position: relative; position: relative;
width: 100%; width: 100%;
height: 280px; min-height: 200px;
max-height: 360px;
overflow: hidden; overflow: hidden;
background: var(--color-surface-container);
} }
.store-detail-carousel { .store-detail-carousel {
width: 100%; width: 100%;
height: 100%; height: 280px;
} }
.store-detail-carousel-item, .store-detail-carousel-item,
@@ -241,6 +243,27 @@
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;
+65 -46
View File
@@ -144,24 +144,25 @@
padding: 4px var(--space-page) 16px; padding: 4px var(--space-page) 16px;
} }
/* 左图右文,卡片等高 */
.store-card { .store-card {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: stretch; align-items: stretch;
gap: 12px; gap: 12px;
padding: 12px; padding: 12px;
box-sizing: border-box;
background: var(--color-card); background: var(--color-card);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
box-shadow: var(--shadow-card); box-shadow: var(--shadow-card);
margin-bottom: 12px; margin-bottom: 12px;
box-sizing: border-box;
} }
.store-card-cover { .store-card-cover {
flex-shrink: 0; flex-shrink: 0;
width: 96px; width: 96px;
height: 96px; height: 96px;
border-radius: var(--radius-md); border-radius: 8px;
background: var(--color-surface-container); background: var(--color-surface-container);
display: block; display: block;
} }
@@ -173,16 +174,24 @@
.store-card-body { .store-card-body {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
height: 96px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between;
box-sizing: border-box;
} }
.store-card-head { .store-card-row {
display: flex; display: flex;
align-items: flex-start; align-items: center;
justify-content: space-between; min-width: 0;
overflow: hidden;
}
/* 第1行:加粗标题(单行截断)+ 右对齐距离 */
.store-card-row--head {
gap: 8px; gap: 8px;
margin-bottom: 6px; height: 22px;
} }
.store-card-name { .store-card-name {
@@ -191,78 +200,88 @@
font-family: var(--font-headline); font-family: var(--font-headline);
font-size: 15px; font-size: 15px;
font-weight: 700; font-weight: 700;
line-height: 1.35; line-height: 22px;
color: var(--color-on-surface); color: #1a1a1a;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
display: -webkit-box; white-space: nowrap;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
} }
.store-card-distance { .store-card-distance {
flex-shrink: 0; flex-shrink: 0;
max-width: 40%;
font-size: 12px; font-size: 12px;
line-height: 1.35; font-weight: 400;
color: var(--color-subtle-gray); line-height: 22px;
color: #999;
text-align: right;
white-space: nowrap;
} }
.store-card-status-row { /* 第2行:营业状态 + 营业时间(单行) */
display: flex; .store-card-row--meta {
align-items: center;
flex-wrap: wrap;
gap: 6px; gap: 6px;
margin-bottom: 4px; height: 20px;
} }
.store-card-status { .store-card-status {
flex-shrink: 0; flex-shrink: 0;
padding: 2px 8px; padding: 0 6px;
border-radius: var(--radius-sm); border-radius: 4px;
background: rgba(45, 106, 79, 0.12); background: rgba(45, 106, 79, 0.12);
color: var(--color-success-green); color: #2d6a4f;
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
line-height: 1.4; line-height: 18px;
white-space: nowrap;
} }
.store-card-hours { .store-card-hours {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
font-size: 11px; font-size: 11px;
line-height: 1.4; font-weight: 400;
color: var(--color-subtle-gray); line-height: 20px;
color: #999;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 第3行:地址(单行截断)+ 右对齐去核销 */
.store-card-row--foot {
gap: 8px;
height: 28px;
} }
.store-card-address { .store-card-address {
font-size: 12px; flex: 1;
line-height: 1.45; min-width: 0;
color: var(--color-subtle-gray); font-size: 9px;
font-weight: 400;
line-height: 28px;
color: #999;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
display: -webkit-box; white-space: nowrap;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.store-card-footer {
display: flex;
align-items: center;
margin-top: auto;
padding-top: 8px;
}
.store-card-footer-spacer {
flex: 1;
} }
.store-card-cta { .store-card-cta {
flex-shrink: 0; flex-shrink: 0;
padding: 6px 16px; display: flex;
border-radius: var(--radius-full); align-items: center;
background: var(--color-heritage-red); justify-content: center;
color: #fff; padding: 0 14px;
height: 26px;
border-radius: 999px;
background: var(--color-heritage-red, #a61d24);
box-sizing: border-box;
}
.store-card-cta-text {
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
line-height: 1.4; line-height: 26px;
color: #fff;
white-space: nowrap;
} }
+1 -1
View File
@@ -183,7 +183,7 @@ export const STORE_SETTLEMENT_DEFAULT_RATE = 0.6;
/** 酒厂账单结算比例(酒单实付 × 比例),暂定 30% */ /** 酒厂账单结算比例(酒单实付 × 比例),暂定 30% */
export const WINERY_SETTLEMENT_RATE = 0.3; export const WINERY_SETTLEMENT_RATE = 0.3;
/** 酒厂账单出账滞后自然日(T+3:支付日 + 3 天后纳入账单) */ /** 酒厂账单出账滞后自然日(T+3:订单完成日 + 3 天后纳入账单) */
export const WINERY_SETTLEMENT_LAG_DAYS = 3; export const WINERY_SETTLEMENT_LAG_DAYS = 3;
export type { LogisticsSettlementMethod } from './enums'; export type { LogisticsSettlementMethod } from './enums';
@@ -33,11 +33,24 @@ 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;
@@ -59,6 +72,7 @@ export interface CreateSupportTicketRequest {
content?: string; content?: string;
remark?: string; remark?: string;
attachmentUrls?: string[]; attachmentUrls?: string[];
priority?: SupportTicketPriorityDto;
} }
export interface UpdateSupportTicketRequest { export interface UpdateSupportTicketRequest {
@@ -67,6 +81,7 @@ export interface UpdateSupportTicketRequest {
content?: string; content?: string;
remark?: string; remark?: string;
attachmentUrls?: string[]; attachmentUrls?: string[];
priority?: SupportTicketPriorityDto;
} }
export interface RejectSupportTicketRequest { export interface RejectSupportTicketRequest {
+2
View File
@@ -48,6 +48,8 @@ export type ClientRuntimeConfig = {
banners: string[]; banners: string[];
footerUrl: string | null; footerUrl: string | null;
}; };
/** 小程序最低兼容版本(semver,如 3.4.13);客户端低于此值时提示更新 */
minClientVersion?: string | null;
}; };
/** 是否展示微信授权入口 */ /** 是否展示微信授权入口 */
+2 -1
View File
@@ -11,7 +11,8 @@
"./CouponBadge": "./src/CouponBadge.tsx", "./CouponBadge": "./src/CouponBadge.tsx",
"./OrderStatusTabs": "./src/OrderStatusTabs.tsx", "./OrderStatusTabs": "./src/OrderStatusTabs.tsx",
"./AppImage": "./src/AppImage.tsx", "./AppImage": "./src/AppImage.tsx",
"./PullToRefresh": "./src/PullToRefresh.tsx" "./PullToRefresh": "./src/PullToRefresh.tsx",
"./compressImage": "./src/compressImage.ts"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^18.3.1" "react": "^18.3.1"
+90
View File
@@ -0,0 +1,90 @@
/** OSS 单文件上限(与后端 OSS_MAX_UPLOAD_BYTES 默认一致) */
export const DEFAULT_OSS_MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
function isCompressibleImage(file: File): boolean {
if (!file.type.startsWith('image/')) return false;
if (file.type === 'image/svg+xml' || file.type === 'image/gif') return false;
return true;
}
function loadImage(file: File): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
resolve(img);
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('图片读取失败'));
};
img.src = url;
});
}
function canvasToBlob(canvas: HTMLCanvasElement, type: string, quality: number): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error('图片压缩失败'))),
type,
quality,
);
});
}
async function renderScaled(
img: HTMLImageElement,
maxEdge: number,
quality: number,
mime: string,
): Promise<Blob> {
const w = img.naturalWidth;
const h = img.naturalHeight;
const scale = Math.min(1, maxEdge / Math.max(w, h));
const cw = Math.max(1, Math.round(w * scale));
const ch = Math.max(1, Math.round(h * scale));
const canvas = document.createElement('canvas');
canvas.width = cw;
canvas.height = ch;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('图片压缩失败');
ctx.drawImage(img, 0, 0, cw, ch);
return canvasToBlob(canvas, mime, quality);
}
/**
* 超过 maxBytes 的图片用 canvas 压缩后再上传;不可压缩或非图片原样返回。
*/
export async function compressImageFileIfNeeded(
file: File,
maxBytes = DEFAULT_OSS_MAX_UPLOAD_BYTES,
): Promise<File> {
if (!isCompressibleImage(file) || file.size <= maxBytes) return file;
const img = await loadImage(file);
const mime = file.type === 'image/png' || file.type === 'image/webp' ? 'image/jpeg' : file.type || 'image/jpeg';
const edges = [4096, 3072, 2048, 1536, 1280, 1024, 800];
let best: Blob | null = null;
outer: for (const edge of edges) {
for (let quality = 0.92; quality >= 0.5; quality -= 0.08) {
const blob = await renderScaled(img, edge, quality, mime);
if (!best || blob.size < best.size) best = blob;
if (blob.size <= maxBytes) {
best = blob;
break outer;
}
}
}
if (!best || best.size >= file.size) return file;
const ext = mime === 'image/png' ? '.png' : '.jpg';
const baseName = file.name.replace(/\.[^.]+$/, '') || 'image';
return new File([best], `${baseName}${ext}`, { type: mime, lastModified: Date.now() });
}
export function formatOssMaxSizeMb(maxBytes = DEFAULT_OSS_MAX_UPLOAD_BYTES): number {
return Math.floor(maxBytes / 1024 / 1024);
}
+3
View File
@@ -29,6 +29,9 @@ importers:
'@dukang/shared-types': '@dukang/shared-types':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/shared-types version: link:../../packages/shared-types
'@dukang/shared-ui':
specifier: workspace:*
version: link:../../packages/shared-ui
antd: antd:
specifier: ^5.22.0 specifier: ^5.22.0
version: 5.29.3(luxon@3.7.2)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 5.29.3(luxon@3.7.2)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+8
View File
@@ -109,6 +109,13 @@ enum SupportTicketStatus {
PASSED PASSED
} }
enum SupportTicketPriority {
LOW
NORMAL
HIGH
URGENT
}
enum DevPlanTaskType { enum DevPlanTaskType {
BUG BUG
REQUIREMENT REQUIREMENT
@@ -646,6 +653,7 @@ model CommonSupportTicket {
ticketNo String @unique @map("ticket_no") @db.VarChar(32) ticketNo String @unique @map("ticket_no") @db.VarChar(32)
ticketType SupportTicketType @map("ticket_type") ticketType SupportTicketType @map("ticket_type")
status SupportTicketStatus @default(PENDING_REVIEW) status SupportTicketStatus @default(PENDING_REVIEW)
priority SupportTicketPriority @default(NORMAL) @map("priority")
title String @db.VarChar(128) title String @db.VarChar(128)
content String? @db.Text content String? @db.Text
rejectReason String? @map("reject_reason") @db.VarChar(512) rejectReason String? @map("reject_reason") @db.VarChar(512)
@@ -116,6 +116,15 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
requiresRestart: false, requiresRestart: false,
description: '小程序商品首页底部 footer,建议比例 15:4;上传后需点击右上角「保存」', description: '小程序商品首页底部 footer,建议比例 15:4;上传后需点击右上角「保存」',
}, },
{
key: 'MINI_USER_MIN_VERSION',
label: '小程序最低版本',
group: G.wechat_mini,
type: 'string',
requiresRestart: false,
placeholder: '3.4.13',
description: 'semver 格式;客户端低于此版本时提示更新',
},
{ key: 'OSS_ACCESS_KEY_ID', label: 'OSS AccessKey ID', group: G.oss, type: 'password', secret: true, requiresRestart: true }, { key: 'OSS_ACCESS_KEY_ID', label: 'OSS AccessKey ID', group: G.oss, type: 'password', secret: true, requiresRestart: true },
{ key: 'OSS_ACCESS_KEY_SECRET', label: 'OSS AccessKey Secret', group: G.oss, type: 'password', secret: true, requiresRestart: true }, { key: 'OSS_ACCESS_KEY_SECRET', label: 'OSS AccessKey Secret', group: G.oss, type: 'password', secret: true, requiresRestart: true },
@@ -5,7 +5,7 @@ import { AlertService } from '../common/alert/alert.service';
/** /**
* 财务对账单定时任务(Asia/Shanghai * 财务对账单定时任务(Asia/Shanghai
* - 每日 08:00:酒厂日账单 + 门店日账单(统计昨日 00:00~今日 00:00 * - 每日 08:00:酒厂日账单(T+3:3 天前已完成订单)+ 门店日账单(昨日核销
* - 每月 1 日 08:00:合伙人上一自然月账单 + 物流承运商上一自然月对账 * - 每月 1 日 08:00:合伙人上一自然月账单 + 物流承运商上一自然月对账
* - 工作日 18:05:门店提现 T+0 审完预警(FIN-003 * - 工作日 18:05:门店提现 T+0 审完预警(FIN-003
*/ */
@@ -11,6 +11,7 @@ export class ClientConfigController {
const cfg = this.systemConfig.getAppConfig(); const cfg = this.systemConfig.getAppConfig();
const env = this.systemConfig.getMergedEnv(); const env = this.systemConfig.getMergedEnv();
const footer = (env.MINI_HOME_FOOTER_URL ?? '').trim(); const footer = (env.MINI_HOME_FOOTER_URL ?? '').trim();
const minClientVersion = (env.MINI_USER_MIN_VERSION ?? '').trim() || null;
return { return {
mockPay: cfg.mockPay, mockPay: cfg.mockPay,
wechatPayEnabled: cfg.wechatPayEnabled, wechatPayEnabled: cfg.wechatPayEnabled,
@@ -19,6 +20,7 @@ export class ClientConfigController {
wxAuthorize: cfg.wxAuthorize, wxAuthorize: cfg.wxAuthorize,
/** 可选暴露;选点已改为服务端 /common/lbs,前端可不依赖此字段 */ /** 可选暴露;选点已改为服务端 /common/lbs,前端可不依赖此字段 */
tencentLbsKey: cfg.tencentLbsKey || undefined, tencentLbsKey: cfg.tencentLbsKey || undefined,
minClientVersion,
miniHome: { miniHome: {
banners: parseMiniHomeBanners(env.MINI_HOME_BANNERS), banners: parseMiniHomeBanners(env.MINI_HOME_BANNERS),
footerUrl: footer || null, footerUrl: footer || null,
@@ -11,6 +11,10 @@ export class SupportTicketListQueryDto {
@IsIn(['PENDING_REVIEW', 'REJECTED', 'DEVELOPING', 'TESTING', 'PASSED']) @IsIn(['PENDING_REVIEW', 'REJECTED', 'DEVELOPING', 'TESTING', 'PASSED'])
status?: string; status?: string;
@IsOptional()
@IsIn(['LOW', 'NORMAL', 'HIGH', 'URGENT'])
priority?: string;
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
@IsInt() @IsInt()
@@ -46,6 +50,10 @@ export class CreateSupportTicketDto {
@IsArray() @IsArray()
@IsString({ each: true }) @IsString({ each: true })
attachmentUrls?: string[]; attachmentUrls?: string[];
@IsOptional()
@IsIn(['LOW', 'NORMAL', 'HIGH', 'URGENT'])
priority?: string;
} }
export class UpdateSupportTicketDto { export class UpdateSupportTicketDto {
@@ -72,6 +80,10 @@ export class UpdateSupportTicketDto {
@IsArray() @IsArray()
@IsString({ each: true }) @IsString({ each: true })
attachmentUrls?: string[]; attachmentUrls?: string[];
@IsOptional()
@IsIn(['LOW', 'NORMAL', 'HIGH', 'URGENT'])
priority?: string;
} }
export class BatchUpdateSupportTicketStatusDto { export class BatchUpdateSupportTicketStatusDto {
@@ -3,7 +3,7 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import type { SupportTicketStatus, SupportTicketType } from '@prisma/client'; import type { SupportTicketStatus, SupportTicketType, SupportTicketPriority } from '@prisma/client';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import type { CreateDevPlanTaskFromTicketInput } from '@dukang/shared-types'; import type { CreateDevPlanTaskFromTicketInput } from '@dukang/shared-types';
import { mapSupportTicketTypeToDevPlanTask } from '@dukang/shared-types'; import { mapSupportTicketTypeToDevPlanTask } from '@dukang/shared-types';
@@ -68,6 +68,7 @@ export class SupportTicketService {
title: dto.title.trim(), title: dto.title.trim(),
content: dto.content?.trim() || null, content: dto.content?.trim() || null,
remark: dto.remark?.trim() || null, remark: dto.remark?.trim() || null,
priority: (dto.priority as SupportTicketPriority | undefined) ?? 'NORMAL',
attachmentUrls: dto.attachmentUrls?.length attachmentUrls: dto.attachmentUrls?.length
? (dto.attachmentUrls.map((u) => u.trim()).filter(Boolean) as unknown as Prisma.InputJsonValue) ? (dto.attachmentUrls.map((u) => u.trim()).filter(Boolean) as unknown as Prisma.InputJsonValue)
: undefined, : undefined,
@@ -178,6 +179,7 @@ export class SupportTicketService {
const urls = dto.attachmentUrls.map((u) => u.trim()).filter(Boolean); const urls = dto.attachmentUrls.map((u) => u.trim()).filter(Boolean);
data.attachmentUrls = urls.length ? (urls as unknown as Prisma.InputJsonValue) : Prisma.JsonNull; data.attachmentUrls = urls.length ? (urls as unknown as Prisma.InputJsonValue) : Prisma.JsonNull;
} }
if (dto.priority != null) data.priority = dto.priority as SupportTicketPriority;
const updated = await this.prisma.commonSupportTicket.update({ where: { id }, data }); const updated = await this.prisma.commonSupportTicket.update({ where: { id }, data });
return serializeBigInt(mapSupportTicketRow(updated)); return serializeBigInt(mapSupportTicketRow(updated));
} }
@@ -247,6 +249,9 @@ export class SupportTicketService {
if (query.status) { if (query.status) {
where.status = query.status as SupportTicketStatus; where.status = query.status as SupportTicketStatus;
} }
if (query.priority) {
where.priority = query.priority as SupportTicketPriority;
}
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
this.prisma.commonSupportTicket.findMany({ this.prisma.commonSupportTicket.findMany({
@@ -1692,6 +1692,10 @@ export class AuthService {
throw new BadRequestException('首次登录请使用手机验证码,登录后将自动关联微信'); throw new BadRequestException('首次登录请使用手机验证码,登录后将自动关联微信');
} }
if (account.status !== 'ACTIVE') {
throw new BadRequestException('合伙人账号已停用');
}
account = await this.prisma.partnerAccount.update({ account = await this.prisma.partnerAccount.update({
where: { id: account.id }, where: { id: account.id },
data: { data: {
@@ -1581,13 +1581,15 @@ export class SettlementService {
return { billDate: billDate.toISOString().slice(0, 10), skipped: true, reason: '已打款' }; return { billDate: billDate.toISOString().slice(0, 10), skipped: true, reason: '已打款' };
} }
// T+3:纳入「账单日」当天完成的同城/跨城订单(完成日 = 今天 − lagDays)
const orders = await this.prisma.order.findMany({ const orders = await this.prisma.order.findMany({
where: { where: {
status: 'COMPLETED',
payStatus: 'PAID', payStatus: 'PAID',
deliveryType: { in: ['LOCAL', 'CROSS_CITY'] }, deliveryType: { in: ['LOCAL', 'CROSS_CITY'] },
paidAt: { gte: start, lt: end }, completedAt: { gte: start, lt: end },
}, },
orderBy: { paidAt: 'asc' }, orderBy: { completedAt: 'asc' },
}); });
if (orders.length === 0 && !existing) { if (orders.length === 0 && !existing) {
@@ -1633,7 +1635,8 @@ export class SettlementService {
deliveryType: o.deliveryType, deliveryType: o.deliveryType,
payAmount: o.payAmount, payAmount: o.payAmount,
wineryAmount: round2(Number(o.payAmount) * rate), wineryAmount: round2(Number(o.payAmount) * rate),
paidAt: o.paidAt!, // 明细仍保留支付时间;账期窗口按 completedAt 归属
paidAt: o.paidAt ?? o.completedAt!,
})), })),
}); });
} }
+6
View File
@@ -44,6 +44,12 @@
|------|------|------| |------|------|------|
| **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 门店/商品/提货/版本/**配送中物流摘要与详情时间线**、H5 登录校验、合伙人微信暂停禁登、**OSS 图片超 10MB 客户端压缩**;开发设计见 [`杜康好客-v3.4.13-体验优化开发文档.md`](./杜康好客-v3.4.13-体验优化开发文档.md) |
--- ---
## 1. 背景与目标 ## 1. 背景与目标
+19 -1
View File
@@ -330,10 +330,28 @@ C2~C7、C14 见 §1.3。
--- ---
## 10. 变更记录 ## 10. v3.4.13 体验优化(2026-08-05
| 项 | 状态 | 说明 |
|----|------|------|
| 推广码 attributionCount | ✅ | HQ 详情统计卡 |
| 核销用户信息 | ✅ | RedeemRecordsPage 列表+详情 |
| 技术支持优先级 | ✅ | Prisma + shared-types + HQ UI |
| 合伙人微信暂停禁登 | ✅ | loginPartnerWechat |
| H5 登录前端校验 | ✅ | partner/shop LoginPage |
| mini-user 门店体验 | ✅ | 电话脱敏/埋点、门头 aspectFit、套餐折叠 |
| mini-user 商品/提货 | ✅ | 去分享、首图 preview、提货确认弹框 |
| mini-user 版本/物流 | ✅ | minClientVersion + UpdateManager;配送中最新路由摘要 + 物流详情时间线(小飞侠 `/track` |
| 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 改为计划+状态表 |
@@ -0,0 +1,75 @@
# 杜康好客 · v3.4.13 体验优化开发文档
> 版本:**v3.4.13** · 日期:2026-08-05
> 需求源:20 条 ST 工单(体验优化 / Bug / 客户端验证)
## 1. 范围
| 模块 | 内容 |
|------|------|
| admin-web | 推广码 attributionCount;核销记录用户信息;技术支持工单优先级;**大图上传前压缩** |
| mini-user | 门店电话脱敏+拨打埋点;门头/套餐展示;商品去分享+首图 preview;提货确认弹框;版本更新提示;**订单详情配送中物流摘要+详情页时间线**;**头像超 10MB 压缩** |
| h5-partner / h5-shop / h5-user | 登录 phone/code 前端校验;**OSS 图片超 10MB 自动 canvas 压缩后上传** |
| 后端 | 工单 priority 字段;client-config minClientVersion;合伙人微信登录暂停拦截 |
## 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 | 订单物流追踪页 | ✅ 配送中摘要 + 物流详情页时间线(小飞侠路由) |
| — | 上传图片超 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` | 物流路由;小飞侠(`XFX`/`XIAOFEIXIA`)走小飞侠 TRACK_ROUTE,其他承运商返回 manualQueryUrl |
### 4. mini-user 物流(ST1785939375449985
| 页面 | 行为 |
|------|------|
| 订单详情 | 状态为**配送中**(`SHIPPING`/`SHIPPED`)时,状态下方展示最新一条路由信息;右侧「物流详情」进入详情页 |
| 物流详情 | 配送方式、运单号、完整物流动态时间线(状态名 + 描述 + 时间);最新节点高亮 |
| 接口 | `GET /trade/orders/:id` + `GET /trade/orders/:id/track``shouldLoadOrderTrack`:有运单号或小飞侠 provider 时拉路由 |
| 公共模块 | `apps/mini-user/src/lib/order-logistics.ts` |
## 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:低版本弹窗、UpdateManager;配送中展示最新物流 + 物流详情页完整时间线(小飞侠路由)
- [ ] 各端上传 >10MB 图片自动压缩后可上传;仍超限有明确报错
- [ ] `pnpm lint` 无新增错误