merge(dev_jacy): v3.5.18 HQ 明文手机、任务版本与核销快链
This commit is contained in:
@@ -15,7 +15,13 @@ 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
|
||||||
| {
|
| {
|
||||||
@@ -41,7 +47,13 @@ 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="用户昵称">{user?.nickname?.trim() || '—'}</Descriptions.Item>
|
<Descriptions.Item label="用户昵称">
|
||||||
|
{(() => {
|
||||||
|
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>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
@@ -111,9 +111,16 @@ 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>(
|
||||||
@@ -132,6 +139,35 @@ 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);
|
||||||
@@ -154,12 +190,7 @@ export default function BenefitCouponsPage() {
|
|||||||
width: 200,
|
width: 200,
|
||||||
ellipsis: false,
|
ellipsis: false,
|
||||||
render: (v, row) => (
|
render: (v, row) => (
|
||||||
<AdminPrimaryLink
|
<AdminPrimaryLink onClick={() => void openCouponDetail(row.id)}>
|
||||||
onClick={async () => {
|
|
||||||
setDetail(await request(`/admin/benefit/coupons/${row.id}`));
|
|
||||||
setDrawerOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{v}
|
{v}
|
||||||
</AdminPrimaryLink>
|
</AdminPrimaryLink>
|
||||||
),
|
),
|
||||||
@@ -211,10 +242,7 @@ export default function BenefitCouponsPage() {
|
|||||||
<Button
|
<Button
|
||||||
type="link"
|
type="link"
|
||||||
size="small"
|
size="small"
|
||||||
onClick={async () => {
|
onClick={() => void openCouponDetail(row.id)}
|
||||||
setDetail(await request(`/admin/benefit/coupons/${row.id}`));
|
|
||||||
setDrawerOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
详情
|
详情
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -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 (!batchEditOpen) return;
|
if (!modalOpen && !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([]));
|
||||||
}, [batchEditOpen]);
|
}, [modalOpen, batchEditOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!modalOpen || editing) return;
|
if (!modalOpen || editing) return;
|
||||||
@@ -108,6 +108,7 @@ 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);
|
||||||
@@ -121,7 +122,11 @@ 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({ ...values, attachmentUrls }),
|
body: JSON.stringify({
|
||||||
|
...values,
|
||||||
|
attachmentUrls,
|
||||||
|
versionIds: values.versionIds ?? [],
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
message.success('已更新');
|
message.success('已更新');
|
||||||
} else {
|
} else {
|
||||||
@@ -285,6 +290,21 @@ 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 },
|
||||||
{
|
{
|
||||||
@@ -405,9 +425,21 @@ 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 }]}>
|
<>
|
||||||
<Select options={STATUS_OPTIONS} />
|
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
||||||
</Form.Item>
|
<Select options={STATUS_OPTIONS} />
|
||||||
|
</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
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useNavigate, 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,17 +20,28 @@ type Row = {
|
|||||||
channel?: RedeemChannel;
|
channel?: RedeemChannel;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
isTest?: boolean;
|
isTest?: boolean;
|
||||||
user?: { userNo: string; phone: string | null; nickname?: string | null };
|
user?: {
|
||||||
|
id?: string;
|
||||||
|
userNo: string;
|
||||||
|
phone: string | null;
|
||||||
|
nickname?: string | null;
|
||||||
|
hqRemark?: string | null;
|
||||||
|
};
|
||||||
store?: { name: string; cityName: string };
|
store?: { name: string; cityName: string };
|
||||||
coupon?: { couponNo: string };
|
coupon?: { id?: string; couponNo: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
function maskPhone(phone: string | null | undefined) {
|
function formatNicknameWithRemark(user?: {
|
||||||
if (!phone || phone.length < 7) return phone ?? '—';
|
nickname?: string | null;
|
||||||
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
|
hqRemark?: string | null;
|
||||||
|
} | 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();
|
||||||
@@ -108,23 +119,60 @@ export default function RedeemRecordsPage() {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ title: '用户编号', dataIndex: ['user', 'userNo'], width: 110 },
|
{ title: '用户编号', dataIndex: ['user', 'userNo'], width: 110, render: (v: string | undefined, row) =>
|
||||||
|
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: 100,
|
width: 160,
|
||||||
render: (v: string | null | undefined) => v || '—',
|
render: (_: string | null | undefined, row) =>
|
||||||
|
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) => maskPhone(v),
|
render: (v: string | null | undefined) => 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: '操作',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
Statistic,
|
Statistic,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
@@ -75,6 +76,7 @@ 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') || '';
|
||||||
@@ -294,7 +296,11 @@ export default function StoreBillsPage() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '日期',
|
title: (
|
||||||
|
<Tooltip title="T+1 为账单日(对应昨日核销自然日窗口,不是该账单里最后一笔核销时间);手动提现为申请时间">
|
||||||
|
账单日
|
||||||
|
</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)),
|
||||||
@@ -389,6 +395,7 @@ export default function StoreBillsPage() {
|
|||||||
<>
|
<>
|
||||||
含 T+1 自动出账与门店手动提现;按类型筛选,详情与审核动作沿用原接口。
|
含 T+1 自动出账与门店手动提现;按类型筛选,详情与审核动作沿用原接口。
|
||||||
「已打款」= T+1 出账终态,「已结算」= 手动提现终态。
|
「已打款」= T+1 出账终态,「已结算」= 手动提现终态。
|
||||||
|
T+1「账单日」是出账对应的核销自然日(定时任务按昨日窗口生成),不是最后一笔核销时间。
|
||||||
{overdueSummary && overdueSummary.overdueCount > 0 ? (
|
{overdueSummary && overdueSummary.overdueCount > 0 ? (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Text type="danger">
|
<Typography.Text type="danger">
|
||||||
@@ -469,7 +476,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>
|
||||||
@@ -592,8 +599,22 @@ export default function StoreBillsPage() {
|
|||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
title: '核销单号',
|
title: '核销单号',
|
||||||
render: (_, r) =>
|
render: (_, r) => {
|
||||||
String((r.redeemRecord as { redeemNo?: string })?.redeemNo || '—'),
|
const redeemNo = String(
|
||||||
|
(r.redeemRecord as { redeemNo?: string } | undefined)?.redeemNo || '',
|
||||||
|
);
|
||||||
|
return redeemNo ? (
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/redeem-records?redeemNo=${encodeURIComponent(redeemNo)}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{redeemNo}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '金额',
|
title: '金额',
|
||||||
@@ -662,7 +683,18 @@ 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;
|
||||||
return String(payout?.redeemRecord?.redeemNo || '—');
|
const redeemNo = payout?.redeemRecord?.redeemNo || '';
|
||||||
|
return redeemNo ? (
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/redeem-records?redeemNo=${encodeURIComponent(redeemNo)}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{redeemNo}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ 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';
|
||||||
|
|
||||||
|
|
||||||
@@ -560,50 +561,43 @@ export default function SupportTicketsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<div
|
<AdminListHeader
|
||||||
style={{
|
title="技术支持"
|
||||||
display: 'flex',
|
settings={settingsButton}
|
||||||
justifyContent: 'space-between',
|
actions={
|
||||||
alignItems: 'center',
|
<>
|
||||||
marginBottom: 16,
|
{isSuperAdmin ? (
|
||||||
}}
|
<>
|
||||||
>
|
<Button
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
disabled={!selectedRowKeys.length}
|
||||||
技术支持
|
loading={batchCreateSaving}
|
||||||
</Typography.Title>
|
onClick={() => void submitBatchCreateTasks()}
|
||||||
{settingsButton}
|
>
|
||||||
<Space>
|
一键创建任务
|
||||||
{isSuperAdmin ? (
|
</Button>
|
||||||
<>
|
<Button disabled={!selectedRowKeys.length} onClick={openBatchPublish}>
|
||||||
<Button
|
一键发布
|
||||||
disabled={!selectedRowKeys.length}
|
</Button>
|
||||||
loading={batchCreateSaving}
|
<Button disabled={!selectedRowKeys.length} loading={acting} onClick={() => void startBatchPreview()}>
|
||||||
onClick={() => void submitBatchCreateTasks()}
|
批量审核
|
||||||
>
|
</Button>
|
||||||
一键创建任务
|
<Button
|
||||||
</Button>
|
disabled={!selectedRowKeys.length}
|
||||||
<Button disabled={!selectedRowKeys.length} onClick={openBatchPublish}>
|
onClick={() => {
|
||||||
一键发布
|
batchStatusForm.setFieldsValue({ status: 'TESTING', rejectReason: '', note: '' });
|
||||||
</Button>
|
setBatchStatusOpen(true);
|
||||||
<Button disabled={!selectedRowKeys.length} loading={acting} onClick={() => void startBatchPreview()}>
|
}}
|
||||||
批量审核
|
>
|
||||||
</Button>
|
批量改状态
|
||||||
<Button
|
</Button>
|
||||||
disabled={!selectedRowKeys.length}
|
</>
|
||||||
onClick={() => {
|
) : null}
|
||||||
batchStatusForm.setFieldsValue({ status: 'TESTING', rejectReason: '', note: '' });
|
<Button type="primary" onClick={() => setCreateOpen(true)}>
|
||||||
setBatchStatusOpen(true);
|
创建工单
|
||||||
}}
|
</Button>
|
||||||
>
|
</>
|
||||||
批量改状态
|
}
|
||||||
</Button>
|
/>
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
<Button type="primary" onClick={() => setCreateOpen(true)}>
|
|
||||||
创建工单
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
layout="inline"
|
layout="inline"
|
||||||
|
|||||||
@@ -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="手机号">{maskPhone(detail.phone)}</Descriptions.Item>
|
<Descriptions.Item label="手机号">{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 ? maskPhone(detail.mergedInto.phone) : '无手机'})`
|
? `${detail.mergedInto.userNo} (${detail.mergedInto.phone || '无手机'})`
|
||||||
: '—'}
|
: '—'}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="被合并访客数">{detail.mergedFromCount ?? 0}</Descriptions.Item>
|
<Descriptions.Item label="被合并访客数">{detail.mergedFromCount ?? 0}</Descriptions.Item>
|
||||||
|
|||||||
@@ -94,6 +94,10 @@ export interface DevPlanTaskDto {
|
|||||||
|
|
||||||
attachmentUrls?: string[] | null;
|
attachmentUrls?: string[] | null;
|
||||||
|
|
||||||
|
versions?: Array<{ id: string; versionNo: string }>;
|
||||||
|
|
||||||
|
versionIds?: string[];
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -166,6 +170,8 @@ export interface UpdateDevPlanTaskInput {
|
|||||||
|
|
||||||
attachmentUrls?: string[];
|
attachmentUrls?: string[];
|
||||||
|
|
||||||
|
versionIds?: string[];
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -156,10 +156,16 @@ export class DevPlanService {
|
|||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
extras?: { creatorName?: string | null; supportTicketNo?: string | null },
|
extras?: {
|
||||||
|
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),
|
||||||
@@ -188,6 +194,10 @@ export class DevPlanService {
|
|||||||
|
|
||||||
attachmentUrls: parseAttachmentUrls(row.attachmentUrls),
|
attachmentUrls: parseAttachmentUrls(row.attachmentUrls),
|
||||||
|
|
||||||
|
versions,
|
||||||
|
|
||||||
|
versionIds: versions.map((v) => v.id),
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -306,7 +316,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] = await Promise.all([
|
const [names, tickets, versionMap] = await Promise.all([
|
||||||
|
|
||||||
this.loadHqNames(creatorIds),
|
this.loadHqNames(creatorIds),
|
||||||
|
|
||||||
@@ -322,6 +332,8 @@ 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>(
|
||||||
@@ -340,6 +352,8 @@ 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)) ?? [],
|
||||||
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
);
|
);
|
||||||
@@ -374,12 +388,16 @@ 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)) ?? [],
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -508,6 +526,10 @@ 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);
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -606,6 +628,41 @@ 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,6 +62,11 @@ 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 {
|
||||||
|
|||||||
@@ -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 } },
|
user: { select: { id: true, userNo: true, phone: true, nickname: true, hqRemark: 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 } },
|
user: { select: { id: true, userNo: true, phone: true, nickname: true, hqRemark: true } },
|
||||||
store: {
|
store: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user