merge(dev_jacy): v3.5.18 HQ 明文手机、任务版本与核销快链

This commit is contained in:
2026-08-28 13:58:37 +08:00
11 changed files with 302 additions and 88 deletions
@@ -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>
+41 -13
View File
@@ -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>
+38 -6
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 (!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
+59 -11
View File
@@ -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: '操作',
+38 -6
View File
@@ -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>
) : (
'—'
);
}, },
}, },
{ {
+38 -44
View File
@@ -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"
+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="手机号">{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>
+6
View File
@@ -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,