58 Commits

Author SHA1 Message Date
jacy 2b5f42acdf merge(dev): HQ 推广码订单只计已完成并补快链
CI / verify (push) Waiting to run
2026-08-28 12:57:03 +08:00
jacy 71149044ca merge(dev_jacy): HQ 推广码订单只计已完成并补快链 2026-08-28 12:56:02 +08:00
jacy 1ecc6a11df merge(dev): 企微结算账单明细与成交播报用户明文
CI / verify (push) Has been cancelled
2026-08-27 11:46:12 +08:00
jacy cfd42dfc32 merge(dev_jacy): 企微结算账单明细与成交播报用户明文 2026-08-27 11:45:59 +08:00
jacy a423fb71e8 merge(dev): v3.5.14 提交订单/支付成功日志端修复
CI / verify (push) Has been cancelled
2026-08-26 17:25:25 +08:00
jacy 4111e5c0a6 merge(dev_jacy): v3.5.14 提交订单/支付成功日志端修复 2026-08-26 17:25:15 +08:00
jacy 3a9f27eb38 merge(dev): v3.5.12 大屏BGM、中文展示、分类删除与侧栏
CI / verify (push) Has been cancelled
2026-08-26 10:55:40 +08:00
jacy e2f5aa9dd8 merge(dev_jacy): v3.5.12 大屏BGM、中文展示、分类删除与侧栏 2026-08-26 10:55:14 +08:00
jacy 6a5c0cb090 Merge pull request 'Dev' (#53) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/53
2026-08-26 09:56:50 +08:00
jacy 448b9983d3 Merge pull request 'Dev jacy v3.5.11 企业微信通知调整' (#52) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/52
2026-08-26 09:56:36 +08:00
jacy 00bb6a2227 Merge pull request 'Dev' (#51) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/51
2026-08-26 07:16:04 +08:00
jacy 1f06530894 Merge pull request '小程序版本号' (#50) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/50
2026-08-26 07:15:52 +08:00
jacy 93390ccf75 merge(dev): 同城运费路由与配送单信息
CI / verify (push) Has been cancelled
2026-08-25 21:13:05 +08:00
jacy a2976cdc8f merge(dev_jacy): 同城运费路由与配送单信息 2026-08-25 21:12:53 +08:00
jacy 8f2ebbfef4 merge(dev): 补承运商删除操作常量
CI / verify (push) Has been cancelled
2026-08-25 14:32:32 +08:00
jacy ee1a3f4b6c merge(dev_jacy): 补承运商删除操作常量 2026-08-25 14:32:27 +08:00
jacy f2fbf95c53 merge(dev): v3.5.10 同城配送提示与企微客服
CI / verify (push) Has been cancelled
2026-08-25 14:30:43 +08:00
jacy 98d52ff173 merge(dev_jacy): v3.5.10 同城配送提示与企微客服 2026-08-25 14:30:10 +08:00
jacy 43de361e61 merge(dev): 列设置靠右与订单状态多选
CI / verify (push) Has been cancelled
2026-08-25 14:20:39 +08:00
jacy b1511ecb92 merge(dev_jacy): 列设置靠右与订单状态多选 2026-08-25 14:20:13 +08:00
jacy 0b548a2764 Merge pull request 'Dev' (#49) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/49
2026-08-25 12:39:52 +08:00
jacy 30f8d649cb Merge pull request 'v3.5.9版本迭代列表优化' (#48) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/48
2026-08-25 12:39:30 +08:00
jacy 40e8a12596 Merge pull request 'Dev' (#47) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/47
2026-08-25 10:10:20 +08:00
jacy d7241d54b1 Merge pull request '给用户增加“备注”列' (#46) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/46
2026-08-25 10:10:07 +08:00
jacy 3d819dc10b Merge pull request 'Dev jacy v3.5.8 和 v3.5.9 版本更新' (#45) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/45
2026-08-25 09:23:12 +08:00
jacy 5e0691ab35 Merge pull request 'Dev jacy v3.5.8 和 v3.5.9 版本更新' (#44) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/44
2026-08-25 09:22:48 +08:00
jacy 8984465893 Merge pull request 'Dev' (#43) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/43
2026-08-23 19:27:28 +08:00
jacy b90a4e9f8f Merge pull request 'v3.5.7版本迭代' (#42) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/42
2026-08-23 19:27:14 +08:00
jacy 3f208d27a9 Merge pull request 'Dev' (#41) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/41
2026-08-23 17:34:40 +08:00
jacy 6973e3d7f5 Merge pull request '修改订单导出时间错误' (#40) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/40
2026-08-23 17:34:21 +08:00
jacy fa69ced448 Merge pull request 'Dev v3.5.6' (#39) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/39
2026-08-23 16:43:51 +08:00
jacy a144a35c3d Merge pull request 'Dev jacy v3.5.6 版本迭代' (#38) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/38
2026-08-23 16:43:07 +08:00
jacy 18c5abea79 Merge pull request 'v3.5.5版本合并' (#37) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/37
2026-08-23 12:30:04 +08:00
jacy 012626f6bd Merge pull request 'v3.5.5版本上传' (#36) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/36
2026-08-23 12:29:33 +08:00
jacy 5d4ed566de merge(dev): HQ product list as SPU with expandable SKUs
CI / verify (push) Has been cancelled
2026-08-21 23:52:41 +08:00
jacy 3004a65b35 Merge branches 'dev_jacy' and 'dev' of git.yqidian.com:jacy/dukang into dev 2026-08-21 23:52:37 +08:00
jacy 5fdddae41c merge(dev): v3.5.4 规格主图与门店使用规则
CI / verify (push) Has been cancelled
2026-08-21 17:37:20 +08:00
jacy 7d2e4ef57e merge(dev_jacy): v3.5.4 规格主图与门店使用规则 2026-08-21 17:37:09 +08:00
jacy 22c0b03a47 Merge pull request 'Dev' (#35) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/35
2026-08-21 16:24:07 +08:00
jacy 73fd27bdf6 Merge pull request 'v3.5.4版本提交,修改api' (#34) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/34
2026-08-21 16:23:54 +08:00
jacy f2d03e2595 Merge pull request 'v3.5.4' (#33) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/33
2026-08-21 15:50:30 +08:00
jacy cd9696bc1c Merge pull request 'v3.5.4版本提交' (#32) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/32
2026-08-21 15:50:07 +08:00
jacy 0c28c204b2 Merge pull request 'Dev' (#31) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/31
2026-08-20 20:23:20 +08:00
jacy b5d4f8b27c Merge pull request 'v3.5.3版本更新2' (#30) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/30
2026-08-20 20:23:03 +08:00
jacy 9a80f0f8d6 Merge pull request 'v3.5.3版本更新1' (#29) from dev_jacy into dev
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/29
2026-08-20 18:55:43 +08:00
jacy e7f49a9639 Merge pull request 'v3.5.3版本更新1' (#28) from dev_jacy into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/28
2026-08-20 18:55:18 +08:00
jacy f941dcf072 Merge pull request 'Dev' (#27) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/27
2026-08-20 15:06:24 +08:00
jacy e8b585e623 Merge pull request '增加音效逻辑' (#26) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/26
2026-08-20 15:06:07 +08:00
jacy 5702266276 Merge pull request 'Dev jacy' (#25) from dev_jacy into dev
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/25
2026-08-20 13:43:29 +08:00
jacy 3382d36a6c Merge pull request 'Dev jacy 大屏优化' (#24) from dev_jacy into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/24
2026-08-20 13:43:06 +08:00
jacy 9b8e3f1347 Merge pull request 'v3.5.2 大屏调整' (#23) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/23
2026-08-20 12:01:21 +08:00
jacy a8a37fbc4a Merge pull request 'v3.5.2 大屏调整' (#22) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/22
2026-08-20 12:00:57 +08:00
jacy 1440a59fc8 Merge pull request 'Dev' (#21) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/21
2026-08-20 00:54:54 +08:00
jacy e34416a9b3 Merge pull request 'Dev jacy mock 支付调整' (#20) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/20
2026-08-20 00:54:17 +08:00
jacy 11659484c6 Merge pull request 'v3.5.1 版本更新' (#19) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/19
2026-08-19 16:01:31 +08:00
jacy 6c44e9c56f Merge pull request 'v3.5.1 版本更新' (#18) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/18
2026-08-19 16:00:56 +08:00
jacy 0fb7ab7abb Merge pull request 'v3.4.18' (#17) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/17
2026-08-17 23:45:19 +08:00
jacy 905e145af3 Merge pull request 'v3.4.18' (#16) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/16
2026-08-17 23:45:00 +08:00
21 changed files with 211 additions and 586 deletions
-50
View File
@@ -74,56 +74,6 @@ body.admin-col-resizing * {
user-select: none !important; user-select: none !important;
} }
/* 订单页筛选:第一行常驻,第二行起可收起 */
.admin-orders-filter-row {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
align-items: flex-end;
margin-bottom: 12px;
}
.admin-orders-filter-row--tools {
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.admin-orders-filter-item {
margin-bottom: 0 !important;
}
.admin-orders-filter-item--sm {
width: 140px;
}
.admin-orders-filter-item--md {
width: 168px;
}
.admin-orders-filter-item--search {
width: 180px;
}
.admin-orders-filter-item--status {
width: 220px;
min-width: 180px;
flex: 1 1 180px;
max-width: 280px;
}
.admin-orders-filter-item--date {
flex: 1 1 auto;
}
.admin-orders-filter-actions {
margin-left: auto;
}
.admin-orders-date-picker {
width: 240px;
}
/* 列表页顶栏:标题左、主操作右、列设置最右 */ /* 列表页顶栏:标题左、主操作右、列设置最右 */
.admin-list-header { .admin-list-header {
display: flex; display: flex;
@@ -15,13 +15,7 @@ type Props = {
export default function RedeemRecordDetailDescriptions({ detail, maskUserPhone = false }: Props) { export default function RedeemRecordDetailDescriptions({ detail, maskUserPhone = false }: Props) {
const user = detail.user as const user = detail.user as
| { | { userNo?: string; nickname?: string | null; phone?: string | null }
id?: string;
userNo?: string;
nickname?: string | null;
phone?: string | null;
hqRemark?: string | null;
}
| undefined; | undefined;
const store = detail.store as const store = detail.store as
| { | {
@@ -47,13 +41,7 @@ export default function RedeemRecordDetailDescriptions({ detail, maskUserPhone =
<Descriptions.Item label="结算额">¥{Number(detail.settleAmount ?? 0).toFixed(2)}</Descriptions.Item> <Descriptions.Item label="结算额">¥{Number(detail.settleAmount ?? 0).toFixed(2)}</Descriptions.Item>
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt ?? ''))}</Descriptions.Item> <Descriptions.Item label="时间">{fmtTime(String(detail.createdAt ?? ''))}</Descriptions.Item>
<Descriptions.Item label="用户编号">{user?.userNo ?? '—'}</Descriptions.Item> <Descriptions.Item label="用户编号">{user?.userNo ?? '—'}</Descriptions.Item>
<Descriptions.Item label="用户昵称"> <Descriptions.Item label="用户昵称">{user?.nickname?.trim() || '—'}</Descriptions.Item>
{(() => {
const name = user?.nickname?.trim() || '—';
const remark = user?.hqRemark?.trim();
return remark ? `${name}${remark}` : name;
})()}
</Descriptions.Item>
<Descriptions.Item label="用户手机"> <Descriptions.Item label="用户手机">
{maskUserPhone ? maskPhone(user?.phone) : user?.phone || '—'} {maskUserPhone ? maskPhone(user?.phone) : user?.phone || '—'}
</Descriptions.Item> </Descriptions.Item>
+13 -41
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'; import { useState } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
Button, Button,
Descriptions, Descriptions,
@@ -111,16 +111,9 @@ type CouponDetail = Row & {
export default function BenefitCouponsPage() { export default function BenefitCouponsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const [searchParams] = useSearchParams();
const initialCouponNo = searchParams.get('couponNo')?.trim() || '';
const [form] = Form.useForm(); const [form] = Form.useForm();
const [grantForm] = Form.useForm<AdminBenefitGrantRequest>(); const [grantForm] = Form.useForm<AdminBenefitGrantRequest>();
const [filters, setFilters] = useState<Record<string, string>>(() => { const [filters, setFilters] = useState<Record<string, string>>({});
const init: Record<string, string> = {};
if (initialCouponNo) init.couponNo = initialCouponNo;
return init;
});
const [grantOpen, setGrantOpen] = useState(false); const [grantOpen, setGrantOpen] = useState(false);
const [granting, setGranting] = useState(false); const [granting, setGranting] = useState(false);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>( const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
@@ -139,35 +132,6 @@ export default function BenefitCouponsPage() {
const [redeemDetail, setRedeemDetail] = useState<Record<string, unknown> | null>(null); const [redeemDetail, setRedeemDetail] = useState<Record<string, unknown> | null>(null);
const [redeemDrawerOpen, setRedeemDrawerOpen] = useState(false); const [redeemDrawerOpen, setRedeemDrawerOpen] = useState(false);
const [redeemDetailLoading, setRedeemDetailLoading] = useState(false); const [redeemDetailLoading, setRedeemDetailLoading] = useState(false);
const deepLinkOpenedRef = useRef(false);
async function openCouponDetail(id: string) {
setDetail(await request<CouponDetail>(`/admin/benefit/coupons/${id}`));
setDrawerOpen(true);
}
useEffect(() => {
if (initialCouponNo) form.setFieldsValue({ couponNo: initialCouponNo });
}, [form, initialCouponNo]);
useEffect(() => {
const openCouponId = (location.state as { openCouponId?: string } | null)?.openCouponId;
if (openCouponId && !deepLinkOpenedRef.current) {
deepLinkOpenedRef.current = true;
void openCouponDetail(openCouponId).catch((e) => {
message.error(e instanceof Error ? e.message : '加载权益券失败');
});
return;
}
if (!initialCouponNo || deepLinkOpenedRef.current || loading) return;
const first = data?.items?.[0];
if (first && String(first.couponNo) === initialCouponNo) {
deepLinkOpenedRef.current = true;
void openCouponDetail(first.id).catch((e) => {
message.error(e instanceof Error ? e.message : '加载权益券失败');
});
}
}, [data, initialCouponNo, loading, location.state]);
async function openRedeemDetail(redeemId: string) { async function openRedeemDetail(redeemId: string) {
setRedeemDetailLoading(true); setRedeemDetailLoading(true);
@@ -190,7 +154,12 @@ export default function BenefitCouponsPage() {
width: 200, width: 200,
ellipsis: false, ellipsis: false,
render: (v, row) => ( render: (v, row) => (
<AdminPrimaryLink onClick={() => void openCouponDetail(row.id)}> <AdminPrimaryLink
onClick={async () => {
setDetail(await request(`/admin/benefit/coupons/${row.id}`));
setDrawerOpen(true);
}}
>
{v} {v}
</AdminPrimaryLink> </AdminPrimaryLink>
), ),
@@ -242,7 +211,10 @@ export default function BenefitCouponsPage() {
<Button <Button
type="link" type="link"
size="small" size="small"
onClick={() => void openCouponDetail(row.id)} onClick={async () => {
setDetail(await request(`/admin/benefit/coupons/${row.id}`));
setDrawerOpen(true);
}}
> >
</Button> </Button>
+6 -38
View File
@@ -71,11 +71,11 @@ export default function DevPlanTasksPage() {
const [batchForm] = Form.useForm<{ status?: DevPlanTaskStatusDto; versionId?: string }>(); const [batchForm] = Form.useForm<{ status?: DevPlanTaskStatusDto; versionId?: string }>();
useEffect(() => { useEffect(() => {
if (!modalOpen && !batchEditOpen) return; if (!batchEditOpen) return;
request<{ items: DevPlanVersionDto[] }>('/admin/dev-plan/versions?pageSize=100') request<{ items: DevPlanVersionDto[] }>('/admin/dev-plan/versions?pageSize=100')
.then((res) => setVersions(res.items ?? [])) .then((res) => setVersions(res.items ?? []))
.catch(() => setVersions([])); .catch(() => setVersions([]));
}, [modalOpen, batchEditOpen]); }, [batchEditOpen]);
useEffect(() => { useEffect(() => {
if (!modalOpen || editing) return; if (!modalOpen || editing) return;
@@ -108,7 +108,6 @@ export default function DevPlanTasksPage() {
content: row.content, content: row.content,
type: row.type, type: row.type,
status: row.status, status: row.status,
versionIds: row.versionIds?.length ? row.versionIds : row.versions?.map((v) => v.id) ?? [],
attachmentUrls: row.attachmentUrls?.length ? row.attachmentUrls : [''], attachmentUrls: row.attachmentUrls?.length ? row.attachmentUrls : [''],
}); });
setModalOpen(true); setModalOpen(true);
@@ -122,11 +121,7 @@ export default function DevPlanTasksPage() {
if (editing) { if (editing) {
await request(`/admin/dev-plan/tasks/${editing.id}`, { await request(`/admin/dev-plan/tasks/${editing.id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ body: JSON.stringify({ ...values, attachmentUrls }),
...values,
attachmentUrls,
versionIds: values.versionIds ?? [],
}),
}); });
message.success('已更新'); message.success('已更新');
} else { } else {
@@ -290,21 +285,6 @@ export default function DevPlanTasksPage() {
), ),
}, },
{ title: '来源工单', dataIndex: 'supportTicketNo', width: 140, render: (v) => v || '—' }, { title: '来源工单', dataIndex: 'supportTicketNo', width: 140, render: (v) => v || '—' },
{
title: '关联版本',
dataIndex: 'versions',
width: 140,
render: (_, row) =>
row.versions?.length ? (
<Space size={4} wrap>
{row.versions.map((v) => (
<Tag key={v.id}>{v.versionNo}</Tag>
))}
</Space>
) : (
'—'
),
},
{ title: '创建人', dataIndex: 'creatorName', width: 90 }, { title: '创建人', dataIndex: 'creatorName', width: 90 },
{ title: '创建时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '创建时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
@@ -425,21 +405,9 @@ export default function DevPlanTasksPage() {
<Select options={TYPE_OPTIONS} /> <Select options={TYPE_OPTIONS} />
</Form.Item> </Form.Item>
{editing ? ( {editing ? (
<> <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>
<Form.Item name="versionIds" label="关联版本">
<Select
mode="multiple"
allowClear
showSearch
optionFilterProp="label"
placeholder="可选:关联版本"
options={versions.map((v) => ({ value: v.id, label: v.versionNo }))}
/>
</Form.Item>
</>
) : ( ) : (
<Form.Item name="supportTicketId" label="绑定技术支持工单"> <Form.Item name="supportTicketId" label="绑定技术支持工单">
<Select <Select
+109 -131
View File
@@ -4,6 +4,7 @@ import {
Alert, Alert,
Button, Button,
Checkbox, Checkbox,
Col,
Collapse, Collapse,
DatePicker, DatePicker,
Descriptions, Descriptions,
@@ -13,6 +14,7 @@ import {
InputNumber, InputNumber,
Modal, Modal,
Radio, Radio,
Row,
Select, Select,
Space, Space,
Table, Table,
@@ -22,7 +24,6 @@ import {
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import dayjs, { type Dayjs } from 'dayjs'; import dayjs, { type Dayjs } from 'dayjs';
import { DownOutlined, UpOutlined } from '@ant-design/icons';
import { ORDER_TYPE_LABELS } from '@dukang/shared-types'; import { ORDER_TYPE_LABELS } from '@dukang/shared-types';
import { request, type AdminOrderItem, type AdminOrderRow, type HqProfile, type Paginated } from '../lib/api'; import { request, type AdminOrderItem, type AdminOrderRow, type HqProfile, type Paginated } from '../lib/api';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
@@ -149,9 +150,8 @@ type OrderExportFilters = {
orderType?: string; orderType?: string;
cityId?: string; cityId?: string;
receiverPhone?: string; receiverPhone?: string;
userKeyword?: string;
productKeyword?: string;
fulfillmentHold?: boolean; fulfillmentHold?: boolean;
excludeTest?: boolean;
deliveryType?: string; deliveryType?: string;
promoCodeId?: string; promoCodeId?: string;
dateRange?: [Dayjs, Dayjs]; dateRange?: [Dayjs, Dayjs];
@@ -213,8 +213,6 @@ const DATE_PRESETS: Array<{ key: 'day' | 'week' | 'month' | 'quarter' | 'year';
{ key: 'year', label: '当年' }, { key: 'year', label: '当年' },
]; ];
const FILTERS_COLLAPSED_KEY = 'admin-orders-filters-collapsed';
function formatUserWithRemark(user?: { function formatUserWithRemark(user?: {
userNo?: string; userNo?: string;
hqRemark?: string | null; hqRemark?: string | null;
@@ -256,10 +254,9 @@ function buildExportPayload(
if (filters.orderType) payload.orderType = filters.orderType; if (filters.orderType) payload.orderType = filters.orderType;
if (filters.cityId) payload.cityId = filters.cityId; if (filters.cityId) payload.cityId = filters.cityId;
if (filters.receiverPhone) payload.receiverPhone = filters.receiverPhone; if (filters.receiverPhone) payload.receiverPhone = filters.receiverPhone;
if (filters.userKeyword) payload.userKeyword = filters.userKeyword;
if (filters.productKeyword) payload.productKeyword = filters.productKeyword;
if (filters.deliveryType) payload.deliveryType = filters.deliveryType; if (filters.deliveryType) payload.deliveryType = filters.deliveryType;
if (filters.fulfillmentHold) payload.fulfillmentHold = true; if (filters.fulfillmentHold) payload.fulfillmentHold = true;
if (filters.excludeTest) payload.excludeTest = true;
if (filters.promoCodeId) payload.promoCodeId = filters.promoCodeId; if (filters.promoCodeId) payload.promoCodeId = filters.promoCodeId;
if (filters.dateRange?.[0]) payload.createdFrom = filters.dateRange[0].format('YYYY-MM-DD'); if (filters.dateRange?.[0]) payload.createdFrom = filters.dateRange[0].format('YYYY-MM-DD');
if (filters.dateRange?.[1]) payload.createdTo = filters.dateRange[1].format('YYYY-MM-DD'); if (filters.dateRange?.[1]) payload.createdTo = filters.dateRange[1].format('YYYY-MM-DD');
@@ -338,13 +335,6 @@ export default function OrdersPage() {
const [trackOpen, setTrackOpen] = useState(false); const [trackOpen, setTrackOpen] = useState(false);
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
const [exportFormat, setExportFormat] = useState<OrderExportFormat>('xlsx'); const [exportFormat, setExportFormat] = useState<OrderExportFormat>('xlsx');
const [filtersCollapsed, setFiltersCollapsed] = useState(() => {
try {
return localStorage.getItem(FILTERS_COLLAPSED_KEY) === '1';
} catch {
return false;
}
});
const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete'); const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete');
const canProxyOrder = (profile?.permissionKeys ?? []).includes('orders'); const canProxyOrder = (profile?.permissionKeys ?? []).includes('orders');
const canExportOrders = (profile?.permissionKeys ?? []).includes('orders'); const canExportOrders = (profile?.permissionKeys ?? []).includes('orders');
@@ -414,9 +404,8 @@ export default function OrdersPage() {
if (values.orderType) qs.set('orderType', values.orderType); if (values.orderType) qs.set('orderType', values.orderType);
if (values.cityId) qs.set('cityId', values.cityId); if (values.cityId) qs.set('cityId', values.cityId);
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone); if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
if (values.userKeyword) qs.set('userKeyword', values.userKeyword);
if (values.productKeyword) qs.set('productKeyword', values.productKeyword);
if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true'); if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true');
if (values.excludeTest) qs.set('excludeTest', 'true');
if (values.deliveryType) qs.set('deliveryType', values.deliveryType); if (values.deliveryType) qs.set('deliveryType', values.deliveryType);
if (initialPromoCodeId) qs.set('promoCodeId', initialPromoCodeId); if (initialPromoCodeId) qs.set('promoCodeId', initialPromoCodeId);
if (values.dateRange?.[0]) qs.set('createdFrom', values.dateRange[0].format('YYYY-MM-DD')); if (values.dateRange?.[0]) qs.set('createdFrom', values.dateRange[0].format('YYYY-MM-DD'));
@@ -854,72 +843,14 @@ export default function OrdersPage() {
) : null} ) : null}
<Form form={form} layout="vertical" onFinish={() => { setPage(1); void load(); }}> <Form form={form} layout="vertical" onFinish={() => { setPage(1); void load(); }}>
<div className="admin-orders-filter-row"> <Row gutter={[16, 8]}>
<Form.Item name="cityId" label="城市" className="admin-orders-filter-item admin-orders-filter-item--sm"> <Col xs={24} md={14} lg={12}>
<Select <Form.Item label="下单日期" style={{ marginBottom: 12 }}>
allowClear <Space direction="vertical" size={8} style={{ width: '100%' }}>
showSearch <Form.Item name="dateRange" noStyle>
optionFilterProp="label" <DatePicker.RangePicker allowClear style={{ width: '100%' }} />
placeholder="全部" </Form.Item>
options={cities.map((c) => ({ value: c.id, label: c.name }))}
/>
</Form.Item>
<Form.Item name="receiverPhone" label="手机号" className="admin-orders-filter-item admin-orders-filter-item--md">
<Input allowClear placeholder="手机号" />
</Form.Item>
<Form.Item name="userKeyword" label="用户" className="admin-orders-filter-item admin-orders-filter-item--search">
<Input allowClear placeholder="编号/昵称/备注/手机" />
</Form.Item>
<Form.Item name="productKeyword" label="商品" className="admin-orders-filter-item admin-orders-filter-item--search">
<Input allowClear placeholder="名称/规格" />
</Form.Item>
<Form.Item name="status" label="状态" className="admin-orders-filter-item admin-orders-filter-item--status">
<Select
mode="multiple"
allowClear
maxTagCount="responsive"
placeholder="全部"
options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item name="orderType" label="类型" className="admin-orders-filter-item admin-orders-filter-item--sm">
<Select allowClear placeholder="全部" options={ORDER_TYPE_OPTIONS} />
</Form.Item>
<Form.Item name="deliveryType" label="配送" className="admin-orders-filter-item admin-orders-filter-item--sm">
<Select allowClear placeholder="全部" options={DELIVERY_TYPE_OPTIONS} />
</Form.Item>
<Form.Item label=" " colon={false} className="admin-orders-filter-item admin-orders-filter-actions">
<Space size={8} wrap>
<Button type="primary" htmlType="submit"></Button>
<Button
type="link"
icon={filtersCollapsed ? <DownOutlined /> : <UpOutlined />}
onClick={() => {
setFiltersCollapsed((prev) => {
const next = !prev;
try {
localStorage.setItem(FILTERS_COLLAPSED_KEY, next ? '1' : '0');
} catch {
/* ignore */
}
return next;
});
}}
>
{filtersCollapsed ? '展开' : '收起'}
</Button>
</Space>
</Form.Item>
</div>
{!filtersCollapsed ? (
<>
<div className="admin-orders-filter-row">
<Form.Item label="下单日期" className="admin-orders-filter-item admin-orders-filter-item--date">
<Space size={8} wrap> <Space size={8} wrap>
<Form.Item name="dateRange" noStyle>
<DatePicker.RangePicker allowClear className="admin-orders-date-picker" />
</Form.Item>
{DATE_PRESETS.map((preset) => ( {DATE_PRESETS.map((preset) => (
<Button <Button
key={preset.key} key={preset.key}
@@ -930,60 +861,107 @@ export default function OrdersPage() {
</Button> </Button>
))} ))}
</Space> </Space>
</Form.Item> </Space>
</div> </Form.Item>
</Col>
<div className="admin-orders-filter-row admin-orders-filter-row--tools"> <Col xs={24} sm={12} md={8} lg={6}>
{canExportOrders ? ( <Form.Item name="status" label="状态" style={{ marginBottom: 12 }}>
<Space size={12} wrap> <Select
<Radio.Group mode="multiple"
optionType="button" allowClear
buttonStyle="solid" maxTagCount="responsive"
value={exportFormat} placeholder="全部"
onChange={(e) => setExportFormat(e.target.value)} options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
> />
<Radio.Button value="xlsx">Excel</Radio.Button> </Form.Item>
<Radio.Button value="pdf">PDF</Radio.Button> </Col>
</Radio.Group> <Col xs={12} sm={6} md={5} lg={3}>
<Button <Form.Item name="orderType" label="类型" style={{ marginBottom: 12 }}>
disabled={!selectedRowKeys.length} <Select allowClear placeholder="全部" options={ORDER_TYPE_OPTIONS} />
loading={exporting} </Form.Item>
onClick={() => void submitExport('selected')} </Col>
> <Col xs={12} sm={6} md={5} lg={3}>
{selectedRowKeys.length ? `${selectedRowKeys.length}` : ''} <Form.Item name="deliveryType" label="配送" style={{ marginBottom: 12 }}>
</Button> <Select allowClear placeholder="全部" options={DELIVERY_TYPE_OPTIONS} />
<Button loading={exporting} onClick={() => void submitExport('filter')}> </Form.Item>
</Col>
</Button> <Col xs={12} sm={6} md={5} lg={3}>
</Space> <Form.Item name="cityId" label="城市" style={{ marginBottom: 12 }}>
) : <span />} <Select
<Space size={12} wrap> allowClear
showSearch
optionFilterProp="label"
placeholder="全部"
options={cities.map((c) => ({ value: c.id, label: c.name }))}
/>
</Form.Item>
</Col>
<Col xs={12} sm={8} md={6} lg={6}>
<Form.Item name="receiverPhone" label="收货手机" style={{ marginBottom: 12 }}>
<Input allowClear placeholder="手机号" />
</Form.Item>
</Col>
<Col xs={12} sm={8} md={8} lg={8}>
<Form.Item label=" " colon={false} style={{ marginBottom: 12 }}>
<Space size={16} wrap>
<Form.Item name="fulfillmentHold" valuePropName="checked" noStyle> <Form.Item name="fulfillmentHold" valuePropName="checked" noStyle>
<Checkbox></Checkbox> <Checkbox></Checkbox>
</Form.Item> </Form.Item>
<Button <Form.Item name="excludeTest" valuePropName="checked" noStyle>
onClick={() => { <Checkbox></Checkbox>
form.resetFields(); </Form.Item>
setPage(1);
if (initialPromoCodeId || initialOrderNo || initialStatuses.length) navigate('/orders');
else void load();
}}
>
</Button>
{canDeleteOrders ? (
<Button
danger
disabled={!selectedRowKeys.length}
onClick={() => setBatchDeleteOpen(true)}
>
{selectedRowKeys.length ? `${selectedRowKeys.length}` : ''}
</Button>
) : null}
</Space> </Space>
</div> </Form.Item>
</> </Col>
) : null} </Row>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', margin: '8px 0 16px', gap: 12, flexWrap: 'wrap' }}>
{canExportOrders ? (
<Space size={12} wrap>
<Radio.Group
optionType="button"
buttonStyle="solid"
value={exportFormat}
onChange={(e) => setExportFormat(e.target.value)}
>
<Radio.Button value="xlsx">Excel</Radio.Button>
<Radio.Button value="pdf">PDF</Radio.Button>
</Radio.Group>
<Button
disabled={!selectedRowKeys.length}
loading={exporting}
onClick={() => void submitExport('selected')}
>
{selectedRowKeys.length ? `${selectedRowKeys.length}` : ''}
</Button>
<Button loading={exporting} onClick={() => void submitExport('filter')}>
</Button>
</Space>
) : <span />}
<Space size={12} wrap>
<Button type="primary" htmlType="submit"></Button>
<Button
onClick={() => {
form.resetFields();
setPage(1);
if (initialPromoCodeId || initialOrderNo || initialStatuses.length) navigate('/orders');
else void load();
}}
>
</Button>
{canDeleteOrders ? (
<Button
danger
disabled={!selectedRowKeys.length}
onClick={() => setBatchDeleteOpen(true)}
>
{selectedRowKeys.length ? `${selectedRowKeys.length}` : ''}
</Button>
) : null}
</Space>
</div>
</Form> </Form>
<Table <Table
+11 -59
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { Button, Checkbox, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd'; import { Button, Checkbox, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types'; import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
@@ -20,28 +20,17 @@ type Row = {
channel?: RedeemChannel; channel?: RedeemChannel;
createdAt: string; createdAt: string;
isTest?: boolean; isTest?: boolean;
user?: { user?: { userNo: string; phone: string | null; nickname?: string | null };
id?: string;
userNo: string;
phone: string | null;
nickname?: string | null;
hqRemark?: string | null;
};
store?: { name: string; cityName: string }; store?: { name: string; cityName: string };
coupon?: { id?: string; couponNo: string }; coupon?: { couponNo: string };
}; };
function formatNicknameWithRemark(user?: { function maskPhone(phone: string | null | undefined) {
nickname?: string | null; if (!phone || phone.length < 7) return phone ?? '—';
hqRemark?: string | null; return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
} | null) {
const name = user?.nickname?.trim() || '—';
const remark = user?.hqRemark?.trim();
return remark ? `${name}${remark}` : name;
} }
export default function RedeemRecordsPage() { export default function RedeemRecordsPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const initialRedeemNo = searchParams.get('redeemNo')?.trim() || ''; const initialRedeemNo = searchParams.get('redeemNo')?.trim() || '';
const [form] = Form.useForm(); const [form] = Form.useForm();
@@ -119,60 +108,23 @@ export default function RedeemRecordsPage() {
); );
}, },
}, },
{ title: '用户编号', dataIndex: ['user', 'userNo'], width: 110, render: (v: string | undefined, row) => { title: '用户编号', dataIndex: ['user', 'userNo'], width: 110 },
row.user?.id ? (
<AdminPrimaryLink
onClick={() => navigate('/users', { state: { openUserId: String(row.user!.id) } })}
>
{v || '—'}
</AdminPrimaryLink>
) : (
v || '—'
),
},
{ {
title: '用户昵称', title: '用户昵称',
dataIndex: ['user', 'nickname'], dataIndex: ['user', 'nickname'],
width: 160, width: 100,
render: (_: string | null | undefined, row) => render: (v: string | null | undefined) => v || '—',
row.user?.id ? (
<AdminPrimaryLink
onClick={() => navigate('/users', { state: { openUserId: String(row.user!.id) } })}
>
{formatNicknameWithRemark(row.user)}
</AdminPrimaryLink>
) : (
formatNicknameWithRemark(row.user)
),
}, },
{ {
title: '用户手机', title: '用户手机',
dataIndex: ['user', 'phone'], dataIndex: ['user', 'phone'],
width: 120, width: 120,
render: (v: string | null | undefined) => v || '—', 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}` },
{ { title: '券号', dataIndex: ['coupon', 'couponNo'], width: 160 },
title: '券号',
dataIndex: ['coupon', 'couponNo'],
width: 160,
render: (v: string | undefined, row) =>
v ? (
<AdminPrimaryLink
onClick={() =>
navigate(`/benefit/coupons?couponNo=${encodeURIComponent(v)}`, {
state: row.coupon?.id ? { openCouponId: String(row.coupon.id) } : undefined,
})
}
>
{v}
</AdminPrimaryLink>
) : (
'—'
),
},
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
title: '操作', title: '操作',
+7 -39
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { import {
Button, Button,
Card, Card,
@@ -14,7 +14,6 @@ import {
Statistic, Statistic,
Table, Table,
Tag, Tag,
Tooltip,
Typography, Typography,
message, message,
} from 'antd'; } from 'antd';
@@ -76,7 +75,6 @@ const KIND_COLORS: Record<StoreSettlementKind, string> = {
}; };
export default function StoreBillsPage() { export default function StoreBillsPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const initialKind = searchParams.get('kind') === 'WITHDRAW' ? 'WITHDRAW' : ''; const initialKind = searchParams.get('kind') === 'WITHDRAW' ? 'WITHDRAW' : '';
const initialStoreId = searchParams.get('storeId') || ''; const initialStoreId = searchParams.get('storeId') || '';
@@ -296,11 +294,7 @@ export default function StoreBillsPage() {
), ),
}, },
{ {
title: ( title: '日期',
<Tooltip title="T+1 为出账日(昨日 00:00–今日 00:00 窗口中的今天,不是该账单里最后一笔核销时间);手动提现为申请时间">
</Tooltip>
),
dataIndex: 'date', dataIndex: 'date',
width: 160, width: 160,
render: (v, row) => (row.kind === 'T1_BILL' ? String(v || '').slice(0, 10) : fmtTime(v)), render: (v, row) => (row.kind === 'T1_BILL' ? String(v || '').slice(0, 10) : fmtTime(v)),
@@ -395,7 +389,6 @@ export default function StoreBillsPage() {
<> <>
T+1 沿 T+1 沿
= T+1 = = T+1 =
T+1 00:00 00:00
{overdueSummary && overdueSummary.overdueCount > 0 ? ( {overdueSummary && overdueSummary.overdueCount > 0 ? (
<div> <div>
<Typography.Text type="danger"> <Typography.Text type="danger">
@@ -476,7 +469,7 @@ export default function StoreBillsPage() {
}))} }))}
/> />
</Form.Item> </Form.Item>
<Form.Item name="range" label="出账日"> <Form.Item name="range" label="日">
<DatePicker.RangePicker /> <DatePicker.RangePicker />
</Form.Item> </Form.Item>
<Form.Item> <Form.Item>
@@ -550,7 +543,7 @@ export default function StoreBillsPage() {
<> <>
<Descriptions column={1} size="small" bordered> <Descriptions column={1} size="small" bordered>
<Descriptions.Item label="账单号">{String(detail.billNo)}</Descriptions.Item> <Descriptions.Item label="账单号">{String(detail.billNo)}</Descriptions.Item>
<Descriptions.Item label="账日"> <Descriptions.Item label="账日">
{String(detail.billDate || '').slice(0, 10)} {String(detail.billDate || '').slice(0, 10)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="应付"> <Descriptions.Item label="应付">
@@ -599,22 +592,8 @@ export default function StoreBillsPage() {
columns={[ columns={[
{ {
title: '核销单号', title: '核销单号',
render: (_, r) => { render: (_, r) =>
const redeemNo = String( String((r.redeemRecord as { redeemNo?: string })?.redeemNo || '—'),
(r.redeemRecord as { redeemNo?: string } | undefined)?.redeemNo || '',
);
return redeemNo ? (
<AdminPrimaryLink
onClick={() =>
navigate(`/redeem-records?redeemNo=${encodeURIComponent(redeemNo)}`)
}
>
{redeemNo}
</AdminPrimaryLink>
) : (
'—'
);
},
}, },
{ {
title: '金额', title: '金额',
@@ -683,18 +662,7 @@ export default function StoreBillsPage() {
const payout = r.storePayout as const payout = r.storePayout as
| { redeemRecord?: { redeemNo?: string }; payoutAmount?: number } | { redeemRecord?: { redeemNo?: string }; payoutAmount?: number }
| undefined; | undefined;
const redeemNo = payout?.redeemRecord?.redeemNo || ''; return String(payout?.redeemRecord?.redeemNo || '');
return redeemNo ? (
<AdminPrimaryLink
onClick={() =>
navigate(`/redeem-records?redeemNo=${encodeURIComponent(redeemNo)}`)
}
>
{redeemNo}
</AdminPrimaryLink>
) : (
'—'
);
}, },
}, },
{ {
+44 -38
View File
@@ -47,7 +47,6 @@ import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload'; import OssUpload from '../components/OssUpload';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
@@ -561,43 +560,50 @@ export default function SupportTicketsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader <div
title="技术支持" style={{
settings={settingsButton} display: 'flex',
actions={ justifyContent: 'space-between',
<> alignItems: 'center',
{isSuperAdmin ? ( marginBottom: 16,
<> }}
<Button >
disabled={!selectedRowKeys.length} <Typography.Title level={4} style={{ margin: 0 }}>
loading={batchCreateSaving}
onClick={() => void submitBatchCreateTasks()} </Typography.Title>
> {settingsButton}
<Space>
</Button> {isSuperAdmin ? (
<Button disabled={!selectedRowKeys.length} onClick={openBatchPublish}> <>
<Button
</Button> disabled={!selectedRowKeys.length}
<Button disabled={!selectedRowKeys.length} loading={acting} onClick={() => void startBatchPreview()}> loading={batchCreateSaving}
onClick={() => void submitBatchCreateTasks()}
</Button> >
<Button
disabled={!selectedRowKeys.length} </Button>
onClick={() => { <Button disabled={!selectedRowKeys.length} onClick={openBatchPublish}>
batchStatusForm.setFieldsValue({ status: 'TESTING', rejectReason: '', note: '' });
setBatchStatusOpen(true); </Button>
}} <Button disabled={!selectedRowKeys.length} loading={acting} onClick={() => void startBatchPreview()}>
>
</Button>
</Button> <Button
</> disabled={!selectedRowKeys.length}
) : null} onClick={() => {
<Button type="primary" onClick={() => setCreateOpen(true)}> batchStatusForm.setFieldsValue({ status: 'TESTING', rejectReason: '', note: '' });
setBatchStatusOpen(true);
</Button> }}
</> >
}
/> </Button>
</>
) : null}
<Button type="primary" onClick={() => setCreateOpen(true)}>
</Button>
</Space>
</div>
<Form <Form
layout="inline" layout="inline"
+2 -2
View File
@@ -611,7 +611,7 @@ export default function UsersPage() {
<Descriptions.Item label="用户编号">{detail.userNo}</Descriptions.Item> <Descriptions.Item label="用户编号">{detail.userNo}</Descriptions.Item>
<Descriptions.Item label="昵称">{detail.nickname || '—'}</Descriptions.Item> <Descriptions.Item label="昵称">{detail.nickname || '—'}</Descriptions.Item>
<Descriptions.Item label="备注">{detail.hqRemark || '—'}</Descriptions.Item> <Descriptions.Item label="备注">{detail.hqRemark || '—'}</Descriptions.Item>
<Descriptions.Item label="手机号">{detail.phone || '—'}</Descriptions.Item> <Descriptions.Item label="手机号">{maskPhone(detail.phone)}</Descriptions.Item>
<Descriptions.Item label="验手机时间"> <Descriptions.Item label="验手机时间">
{detail.phoneVerifiedAt ? new Date(detail.phoneVerifiedAt).toLocaleString('zh-CN') : '未验证'} {detail.phoneVerifiedAt ? new Date(detail.phoneVerifiedAt).toLocaleString('zh-CN') : '未验证'}
</Descriptions.Item> </Descriptions.Item>
@@ -645,7 +645,7 @@ export default function UsersPage() {
<Descriptions.Item label="deviceKey">{detail.deviceKey || '—'}</Descriptions.Item> <Descriptions.Item label="deviceKey">{detail.deviceKey || '—'}</Descriptions.Item>
<Descriptions.Item label="合并至"> <Descriptions.Item label="合并至">
{detail.mergedInto {detail.mergedInto
? `${detail.mergedInto.userNo} (${detail.mergedInto.phone || '无手机'})` ? `${detail.mergedInto.userNo} (${detail.mergedInto.phone ? maskPhone(detail.mergedInto.phone) : '无手机'})`
: '—'} : '—'}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="被合并访客数">{detail.mergedFromCount ?? 0}</Descriptions.Item> <Descriptions.Item label="被合并访客数">{detail.mergedFromCount ?? 0}</Descriptions.Item>
-1
View File
@@ -112,7 +112,6 @@
- 门店/合伙人/酒厂/物流账单列表、详情、导出展示收款账户(户名、账号、开户行) - 门店/合伙人/酒厂/物流账单列表、详情、导出展示收款账户(户名、账号、开户行)
- 确认打款时可填写打款凭证号(`paymentRef`),已打款后在详情与导出中展示 - 确认打款时可填写打款凭证号(`paymentRef`),已打款后在详情与导出中展示
- 门店账单统一列表:T+1 终态「已打款」、手动提现终态「已结算」(均为 `PAID`,文案区分业务类型) - 门店账单统一列表:T+1 终态「已打款」、手动提现终态「已结算」(均为 `PAID`,文案区分业务类型)
- 门店 T+1「出账日」= 出账当天(核销窗口「昨日 00:00–今日 00:00」中的今天)
- 核销详情展示核销门店主账户收款信息 - 核销详情展示核销门店主账户收款信息
### 3.4~3.8 Wave 能力 ### 3.4~3.8 Wave 能力
-6
View File
@@ -94,10 +94,6 @@ export interface DevPlanTaskDto {
attachmentUrls?: string[] | null; attachmentUrls?: string[] | null;
versions?: Array<{ id: string; versionNo: string }>;
versionIds?: string[];
} }
@@ -170,8 +166,6 @@ export interface UpdateDevPlanTaskInput {
attachmentUrls?: string[]; attachmentUrls?: string[];
versionIds?: string[];
} }
-2
View File
@@ -9,8 +9,6 @@ export interface AdminOrdersListQuery extends AdminListQuery {
status?: string | string[]; status?: string | string[];
orderType?: string; orderType?: string;
userId?: string; userId?: string;
userKeyword?: string;
productKeyword?: string;
cityId?: string; cityId?: string;
receiverPhone?: string; receiverPhone?: string;
fulfillmentHold?: string | boolean; fulfillmentHold?: string | boolean;
-1
View File
@@ -146,7 +146,6 @@ export interface StoreBillDto {
id: string; id: string;
billNo: string; billNo: string;
storeId: string; storeId: string;
/** 出账日 YYYY-MM-DDT+1 窗口「昨日 00:00–今日 00:00」中的今天) */
billDate: string; billDate: string;
redeemCount: number; redeemCount: number;
redeemAmount: number; redeemAmount: number;
@@ -91,12 +91,12 @@ async function main() {
await prisma.storeBill.deleteMany({ where: { billNo: { startsWith: 'SBMOCK' } } }); await prisma.storeBill.deleteMany({ where: { billNo: { startsWith: 'SBMOCK' } } });
await prisma.partnerBill.deleteMany({ where: { billNo: { startsWith: 'PBMOCK' } } }); await prisma.partnerBill.deleteMany({ where: { billNo: { startsWith: 'PBMOCK' } } });
// ─── 1. 门店账单(今日出账未打款 + 昨日出账已打款)─── // ─── 1. 门店账单(昨日未打款 + 前日已打款)───
const storeBillUnpaid = await prisma.storeBill.create({ const storeBillUnpaid = await prisma.storeBill.create({
data: { data: {
billNo: billNo('SB', `${yesterday.getTime()}U`), billNo: billNo('SB', `${yesterday.getTime()}U`),
storeId: store.id, storeId: store.id,
billDate: today, billDate: yesterday,
redeemCount: 5, redeemCount: 5,
redeemAmount: 1500, redeemAmount: 1500,
settlementRate: 0.6, settlementRate: 0.6,
@@ -108,7 +108,7 @@ async function main() {
data: { data: {
billNo: billNo('SB', `${dayBefore.getTime()}P`), billNo: billNo('SB', `${dayBefore.getTime()}P`),
storeId: store.id, storeId: store.id,
billDate: yesterday, billDate: dayBefore,
redeemCount: 3, redeemCount: 3,
redeemAmount: 800, redeemAmount: 800,
settlementRate: 0.6, settlementRate: 0.6,
@@ -5,7 +5,7 @@ import { AlertService } from '../common/alert/alert.service';
/** /**
* 财务对账单定时任务(Asia/Shanghai * 财务对账单定时任务(Asia/Shanghai
* - 每日 08:00:酒厂日账单(T+3:3 天前已完成订单)+ 门店日账单(昨日核销,出账日=今天 * - 每日 08:00:酒厂日账单(T+3:3 天前已完成订单)+ 门店日账单(昨日核销)
* - 每月 1 日 08:00:合伙人上一自然月账单 + 物流承运商上一自然月对账 * - 每月 1 日 08:00:合伙人上一自然月账单 + 物流承运商上一自然月对账
* - 工作日 18:05:门店提现 T+0 审完预警(FIN-003 * - 工作日 18:05:门店提现 T+0 审完预警(FIN-003
*/ */
@@ -156,16 +156,10 @@ export class DevPlanService {
}, },
extras?: { extras?: { creatorName?: string | null; supportTicketNo?: string | null },
creatorName?: string | null;
supportTicketNo?: string | null;
versions?: Array<{ id: string; versionNo: string }>;
},
): DevPlanTaskDto { ): DevPlanTaskDto {
const versions = extras?.versions ?? [];
return { return {
id: String(row.id), id: String(row.id),
@@ -194,10 +188,6 @@ export class DevPlanService {
attachmentUrls: parseAttachmentUrls(row.attachmentUrls), attachmentUrls: parseAttachmentUrls(row.attachmentUrls),
versions,
versionIds: versions.map((v) => v.id),
}; };
} }
@@ -316,7 +306,7 @@ export class DevPlanService {
const ticketIds = rows.map((r) => r.supportTicketId).filter((id): id is bigint => id != null); const ticketIds = rows.map((r) => r.supportTicketId).filter((id): id is bigint => id != null);
const [names, tickets, versionMap] = await Promise.all([ const [names, tickets] = await Promise.all([
this.loadHqNames(creatorIds), this.loadHqNames(creatorIds),
@@ -332,8 +322,6 @@ export class DevPlanService {
: Promise.resolve([]), : Promise.resolve([]),
this.loadTaskVersionMap(rows.map((r) => r.id)),
]); ]);
const ticketMap = new Map<string, string>( const ticketMap = new Map<string, string>(
@@ -352,8 +340,6 @@ export class DevPlanService {
supportTicketNo: r.supportTicketId != null ? ticketMap.get(String(r.supportTicketId)) ?? null : null, supportTicketNo: r.supportTicketId != null ? ticketMap.get(String(r.supportTicketId)) ?? null : null,
versions: versionMap.get(String(r.id)) ?? [],
}), }),
); );
@@ -388,16 +374,12 @@ export class DevPlanService {
} }
const versionMap = await this.loadTaskVersionMap([row.id]);
return this.mapTask(row, { return this.mapTask(row, {
creatorName: names.get(String(row.creatorHqAccountId)) ?? null, creatorName: names.get(String(row.creatorHqAccountId)) ?? null,
supportTicketNo, supportTicketNo,
versions: versionMap.get(String(row.id)) ?? [],
}); });
} }
@@ -526,10 +508,6 @@ export class DevPlanService {
await this.prisma.devPlanTask.update({ where: { id }, data }); await this.prisma.devPlanTask.update({ where: { id }, data });
if (dto.versionIds !== undefined) {
await this.replaceTaskVersions(id, dto.versionIds);
}
return this.getTask(id); return this.getTask(id);
} }
@@ -628,41 +606,6 @@ export class DevPlanService {
private async loadTaskVersionMap(
taskIds: bigint[],
): Promise<Map<string, Array<{ id: string; versionNo: string }>>> {
const map = new Map<string, Array<{ id: string; versionNo: string }>>();
if (!taskIds.length) return map;
const links = await this.prisma.devPlanVersionTask.findMany({
where: { taskId: { in: taskIds } },
include: { version: { select: { id: true, versionNo: true } } },
orderBy: { versionId: 'asc' },
});
for (const link of links) {
const key = String(link.taskId);
const list = map.get(key) ?? [];
list.push({ id: String(link.version.id), versionNo: link.version.versionNo });
map.set(key, list);
}
return map;
}
private async replaceTaskVersions(taskId: bigint, versionIds: string[]) {
const unique = [...new Set(versionIds.map((id) => id.trim()).filter(Boolean))];
const ids = unique.map(BigInt);
if (ids.length) {
const versions = await this.prisma.devPlanVersion.findMany({
where: { id: { in: ids } },
select: { id: true },
});
if (versions.length !== ids.length) throw new BadRequestException('部分版本不存在');
}
await this.prisma.devPlanVersionTask.deleteMany({ where: { taskId } });
for (const versionId of ids) {
await this.appendVersionTasks(versionId, [String(taskId)]);
}
}
private async loadVersionTasks(versionId: bigint): Promise<{ tasks: DevPlanTaskDto[]; taskIds: string[] }> { private async loadVersionTasks(versionId: bigint): Promise<{ tasks: DevPlanTaskDto[]; taskIds: string[] }> {
const links = await this.prisma.devPlanVersionTask.findMany({ const links = await this.prisma.devPlanVersionTask.findMany({
@@ -62,11 +62,6 @@ export class UpdateDevPlanTaskDto {
@IsArray() @IsArray()
@IsString({ each: true }) @IsString({ each: true })
attachmentUrls?: string[]; attachmentUrls?: string[];
@IsOptional()
@IsArray()
@IsString({ each: true })
versionIds?: string[];
} }
export class CreateDevPlanVersionDto { export class CreateDevPlanVersionDto {
@@ -30,8 +30,6 @@ type OrderFilterInput = Pick<
| 'status' | 'status'
| 'orderType' | 'orderType'
| 'userId' | 'userId'
| 'userKeyword'
| 'productKeyword'
| 'cityId' | 'cityId'
| 'receiverPhone' | 'receiverPhone'
| 'fulfillmentHold' | 'fulfillmentHold'
@@ -179,28 +177,8 @@ export class AdminOrdersService {
} }
if (query.orderType) where.orderType = query.orderType as Prisma.EnumOrderTypeFilter['equals']; if (query.orderType) where.orderType = query.orderType as Prisma.EnumOrderTypeFilter['equals'];
if (query.userId) where.userId = BigInt(query.userId); if (query.userId) where.userId = BigInt(query.userId);
const userKeyword = query.userKeyword?.trim();
if (userKeyword) {
const userOr: Prisma.UserWhereInput[] = [
{ userNo: { contains: userKeyword } },
{ nickname: { contains: userKeyword } },
{ hqRemark: { contains: userKeyword } },
{ phone: { contains: userKeyword } },
];
if (/^\d+$/.test(userKeyword)) {
userOr.push({ id: BigInt(userKeyword) });
}
where.user = { OR: userOr };
}
if (query.cityId) where.cityId = BigInt(query.cityId); if (query.cityId) where.cityId = BigInt(query.cityId);
if (query.receiverPhone) where.receiverPhone = { contains: query.receiverPhone }; if (query.receiverPhone) where.receiverPhone = { contains: query.receiverPhone };
const productKeyword = query.productKeyword?.trim();
if (productKeyword) {
where.OR = [
{ productName: { contains: productKeyword } },
{ productSpec: { contains: productKeyword } },
];
}
if (query.deliveryType) { if (query.deliveryType) {
where.deliveryType = query.deliveryType as Prisma.EnumDeliveryTypeFilter['equals']; where.deliveryType = query.deliveryType as Prisma.EnumDeliveryTypeFilter['equals'];
} }
@@ -54,7 +54,7 @@ export class AdminRedeemService {
skip: (page - 1) * pageSize, skip: (page - 1) * pageSize,
take: pageSize, take: pageSize,
include: { include: {
user: { select: { id: true, userNo: true, phone: true, nickname: true, hqRemark: true } }, user: { select: { id: true, userNo: true, phone: true, nickname: true } },
store: { select: { id: true, name: true, cityName: true } }, store: { select: { id: true, name: true, cityName: true } },
coupon: { select: { id: true, couponNo: true, balance: true } }, coupon: { select: { id: true, couponNo: true, balance: true } },
}, },
@@ -77,7 +77,7 @@ export class AdminRedeemService {
const record = await this.prisma.redeemRecord.findUnique({ const record = await this.prisma.redeemRecord.findUnique({
where: { id }, where: { id },
include: { include: {
user: { select: { id: true, userNo: true, phone: true, nickname: true, hqRemark: true } }, user: { select: { id: true, userNo: true, phone: true, nickname: true } },
store: { store: {
select: { select: {
id: true, id: true,
@@ -93,16 +93,6 @@ export class AdminOrdersQueryDto extends PaginationQueryDto {
@IsString() @IsString()
userId?: string; userId?: string;
/** 用户编号 / 昵称 / 备注 / 手机 模糊匹配 */
@IsOptional()
@IsString()
userKeyword?: string;
/** 商品名称 / 规格 模糊匹配 */
@IsOptional()
@IsString()
productKeyword?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
cityId?: string; cityId?: string;
@@ -169,14 +159,6 @@ export class AdminOrdersExportDto {
@IsString() @IsString()
cityId?: string; cityId?: string;
@IsOptional()
@IsString()
userKeyword?: string;
@IsOptional()
@IsString()
productKeyword?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
receiverPhone?: string; receiverPhone?: string;
@@ -1,4 +1,4 @@
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleInit } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { import {
DEFAULT_STORE_WITHDRAW_DAILY_LIMIT, DEFAULT_STORE_WITHDRAW_DAILY_LIMIT,
@@ -55,16 +55,11 @@ function startOfDay(d: Date) {
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0); return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
} }
/** 核销窗口:昨日 00:00 ≤ t < 今日 00:00;出账日 = 今日 00:00(窗口右端) */
function dayWindow(anchor = new Date()) { function dayWindow(anchor = new Date()) {
const end = startOfDay(anchor); const end = startOfDay(anchor);
const start = new Date(end); const start = new Date(end);
start.setDate(start.getDate() - 1); start.setDate(start.getDate() - 1);
return { start, end, billDate: end }; return { start, end, billDate: start };
}
function shanghaiYmd(d: Date): string {
return d.toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' });
} }
function wineryDayWindow(anchor = new Date(), lagDays = WINERY_SETTLEMENT_LAG_DAYS) { function wineryDayWindow(anchor = new Date(), lagDays = WINERY_SETTLEMENT_LAG_DAYS) {
@@ -136,9 +131,7 @@ function isWithdrawOverdue(appliedAt: Date, now = new Date()): boolean {
} }
@Injectable() @Injectable()
export class SettlementService implements OnModuleInit { export class SettlementService {
private readonly logger = new Logger(SettlementService.name);
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly analyticsService: AnalyticsService, private readonly analyticsService: AnalyticsService,
@@ -148,37 +141,6 @@ export class SettlementService implements OnModuleInit {
private readonly wecomPush: WecomMessagePushService, private readonly wecomPush: WecomMessagePushService,
) {} ) {}
async onModuleInit() {
try {
const shifted = await this.realignStoreBillIssueDates();
if (shifted > 0) this.logger.log(`Store bill dates aligned to issue day: ${shifted}`);
} catch (e) {
this.logger.warn(`Store bill date align skipped: ${e instanceof Error ? e.message : e}`);
}
}
/**
* 旧数据 billDate=核销自然日(窗口左端);现改为出账日(窗口右端=今天)。
* 按 created_at 日历日对比,只把仍早一天的行 +1;从晚到早更新避免 (storeId,billDate) 冲突。
*/
private async realignStoreBillIssueDates(): Promise<number> {
const locked = await this.prisma.$queryRaw<Array<{ acquired: number | bigint | null }>>`
SELECT GET_LOCK('store_bill_issue_date_align', 5) AS acquired
`;
if (!Number(locked[0]?.acquired)) return 0;
try {
const shifted = await this.prisma.$executeRaw`
UPDATE store_bill
SET bill_date = DATE_ADD(bill_date, INTERVAL 1 DAY)
WHERE DATE(bill_date) < DATE(created_at)
ORDER BY bill_date DESC
`;
return Number(shifted);
} finally {
await this.prisma.$queryRaw`SELECT RELEASE_LOCK('store_bill_issue_date_align')`;
}
}
private notifyPartnerBillDigest( private notifyPartnerBillDigest(
period: string, period: string,
rows: Array<{ rows: Array<{
@@ -1013,7 +975,7 @@ export class SettlementService implements OnModuleInit {
// ─── Store bills (daily header) ────────────────────── // ─── Store bills (daily header) ──────────────────────
/** 生成昨日核销窗口的门店对账单billDate = 出账日(今日) */ /** 生成指定自然日窗口的门店对账单(默认昨日) */
async generateStoreBillsForDay(anchor = new Date()) { async generateStoreBillsForDay(anchor = new Date()) {
const { start, end, billDate } = dayWindow(anchor); const { start, end, billDate } = dayWindow(anchor);
const payouts = await this.prisma.storePayout.findMany({ const payouts = await this.prisma.storePayout.findMany({
@@ -1138,7 +1100,7 @@ export class SettlementService implements OnModuleInit {
}); });
} }
const period = shanghaiYmd(billDate); const period = billDate.toISOString().slice(0, 10);
if (createdBills.length) { if (createdBills.length) {
const banks = await loadStorePrimaryBanksMap( const banks = await loadStorePrimaryBanksMap(
this.prisma, this.prisma,
@@ -1303,7 +1265,6 @@ export class SettlementService implements OnModuleInit {
return serializeBigInt({ return serializeBigInt({
items: slice.map((r) => ({ items: slice.map((r) => ({
...r, ...r,
date: r.kind === 'T1_BILL' ? shanghaiYmd(r.date) : r.date,
bankAccount: bankAccount:
r.kind === 'T1_BILL' r.kind === 'T1_BILL'
? bankMap.get(String(r.storeId)) ?? null ? bankMap.get(String(r.storeId)) ?? null
@@ -1358,13 +1319,7 @@ export class SettlementService implements OnModuleInit {
totalAmount: Number(aggregates._sum.payoutAmount ?? 0), totalAmount: Number(aggregates._sum.payoutAmount ?? 0),
}; };
return serializeBigInt({ return serializeBigInt({ items, total, page, pageSize, summary });
items: items.map((b) => ({ ...b, billDate: shanghaiYmd(b.billDate) })),
total,
page,
pageSize,
summary,
});
} }
async getAdminStoreBill(id: bigint) { async getAdminStoreBill(id: bigint) {
@@ -1380,7 +1335,7 @@ export class SettlementService implements OnModuleInit {
}); });
if (!bill) throw new NotFoundException('门店对账单不存在'); if (!bill) throw new NotFoundException('门店对账单不存在');
const storeAccount = await loadStorePrimaryBank(this.prisma, bill.storeId); const storeAccount = await loadStorePrimaryBank(this.prisma, bill.storeId);
return serializeBigInt({ ...bill, billDate: shanghaiYmd(bill.billDate), storeAccount }); return serializeBigInt({ ...bill, storeAccount });
} }
async confirmStoreBill(id: bigint, dto: { paymentRef?: string } = {}) { async confirmStoreBill(id: bigint, dto: { paymentRef?: string } = {}) {
@@ -1438,7 +1393,7 @@ export class SettlementService implements OnModuleInit {
); );
const header = [ const header = [
'账单号', '账单号',
'账日', '账日',
'门店', '门店',
'城市', '城市',
'核销笔数', '核销笔数',
@@ -1456,7 +1411,7 @@ export class SettlementService implements OnModuleInit {
const bank = bankMap.get(String(b.storeId)); const bank = bankMap.get(String(b.storeId));
return [ return [
csvEscape(b.billNo), csvEscape(b.billNo),
shanghaiYmd(b.billDate), b.billDate.toISOString().slice(0, 10),
csvEscape(b.store.name), csvEscape(b.store.name),
csvEscape(b.store.cityName ?? ''), csvEscape(b.store.cityName ?? ''),
b.redeemCount, b.redeemCount,