feat(admin): v3.5.18 用户详情明文手机、任务关联版本与核销快链
HQ 后台:用户/核销记录展示完整手机号;任务列表与编辑可关联版本;账单核销单号与券号/用户可快链;门店账单日标明为出账自然日;技术支持操作按钮右对齐。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -15,7 +15,13 @@ type Props = {
|
||||
|
||||
export default function RedeemRecordDetailDescriptions({ detail, maskUserPhone = false }: Props) {
|
||||
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;
|
||||
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="时间">{fmtTime(String(detail.createdAt ?? ''))}</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="用户手机">
|
||||
{maskUserPhone ? maskPhone(user?.phone) : user?.phone || '—'}
|
||||
</Descriptions.Item>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
Descriptions,
|
||||
@@ -111,9 +111,16 @@ type CouponDetail = Row & {
|
||||
|
||||
export default function BenefitCouponsPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialCouponNo = searchParams.get('couponNo')?.trim() || '';
|
||||
const [form] = Form.useForm();
|
||||
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 [granting, setGranting] = useState(false);
|
||||
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 [redeemDrawerOpen, setRedeemDrawerOpen] = 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) {
|
||||
setRedeemDetailLoading(true);
|
||||
@@ -154,12 +190,7 @@ export default function BenefitCouponsPage() {
|
||||
width: 200,
|
||||
ellipsis: false,
|
||||
render: (v, row) => (
|
||||
<AdminPrimaryLink
|
||||
onClick={async () => {
|
||||
setDetail(await request(`/admin/benefit/coupons/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}
|
||||
>
|
||||
<AdminPrimaryLink onClick={() => void openCouponDetail(row.id)}>
|
||||
{v}
|
||||
</AdminPrimaryLink>
|
||||
),
|
||||
@@ -211,10 +242,7 @@ export default function BenefitCouponsPage() {
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
setDetail(await request(`/admin/benefit/coupons/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}
|
||||
onClick={() => void openCouponDetail(row.id)}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
|
||||
@@ -71,11 +71,11 @@ export default function DevPlanTasksPage() {
|
||||
const [batchForm] = Form.useForm<{ status?: DevPlanTaskStatusDto; versionId?: string }>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!batchEditOpen) return;
|
||||
if (!modalOpen && !batchEditOpen) return;
|
||||
request<{ items: DevPlanVersionDto[] }>('/admin/dev-plan/versions?pageSize=100')
|
||||
.then((res) => setVersions(res.items ?? []))
|
||||
.catch(() => setVersions([]));
|
||||
}, [batchEditOpen]);
|
||||
}, [modalOpen, batchEditOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!modalOpen || editing) return;
|
||||
@@ -108,6 +108,7 @@ export default function DevPlanTasksPage() {
|
||||
content: row.content,
|
||||
type: row.type,
|
||||
status: row.status,
|
||||
versionIds: row.versionIds?.length ? row.versionIds : row.versions?.map((v) => v.id) ?? [],
|
||||
attachmentUrls: row.attachmentUrls?.length ? row.attachmentUrls : [''],
|
||||
});
|
||||
setModalOpen(true);
|
||||
@@ -121,7 +122,11 @@ export default function DevPlanTasksPage() {
|
||||
if (editing) {
|
||||
await request(`/admin/dev-plan/tasks/${editing.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ ...values, attachmentUrls }),
|
||||
body: JSON.stringify({
|
||||
...values,
|
||||
attachmentUrls,
|
||||
versionIds: values.versionIds ?? [],
|
||||
}),
|
||||
});
|
||||
message.success('已更新');
|
||||
} else {
|
||||
@@ -285,6 +290,21 @@ export default function DevPlanTasksPage() {
|
||||
),
|
||||
},
|
||||
{ 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: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
@@ -405,9 +425,21 @@ export default function DevPlanTasksPage() {
|
||||
<Select options={TYPE_OPTIONS} />
|
||||
</Form.Item>
|
||||
{editing ? (
|
||||
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
||||
<Select options={STATUS_OPTIONS} />
|
||||
</Form.Item>
|
||||
<>
|
||||
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
||||
<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="绑定技术支持工单">
|
||||
<Select
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 type { ColumnsType } from 'antd/es/table';
|
||||
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
|
||||
@@ -20,17 +20,28 @@ type Row = {
|
||||
channel?: RedeemChannel;
|
||||
createdAt: string;
|
||||
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 };
|
||||
coupon?: { couponNo: string };
|
||||
coupon?: { id?: string; couponNo: string };
|
||||
};
|
||||
|
||||
function maskPhone(phone: string | null | undefined) {
|
||||
if (!phone || phone.length < 7) return phone ?? '—';
|
||||
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
|
||||
function formatNicknameWithRemark(user?: {
|
||||
nickname?: string | null;
|
||||
hqRemark?: string | null;
|
||||
} | null) {
|
||||
const name = user?.nickname?.trim() || '—';
|
||||
const remark = user?.hqRemark?.trim();
|
||||
return remark ? `${name}(${remark})` : name;
|
||||
}
|
||||
|
||||
export default function RedeemRecordsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialRedeemNo = searchParams.get('redeemNo')?.trim() || '';
|
||||
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: '用户昵称',
|
||||
dataIndex: ['user', 'nickname'],
|
||||
width: 100,
|
||||
render: (v: string | null | undefined) => v || '—',
|
||||
width: 160,
|
||||
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: '用户手机',
|
||||
dataIndex: ['user', 'phone'],
|
||||
width: 120,
|
||||
render: (v: string | null | undefined) => maskPhone(v),
|
||||
render: (v: string | null | undefined) => v || '—',
|
||||
},
|
||||
{ title: '门店', dataIndex: ['store', 'name'] },
|
||||
{ title: '核销额', dataIndex: 'amount', 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: '操作',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Statistic,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
@@ -75,6 +76,7 @@ const KIND_COLORS: Record<StoreSettlementKind, string> = {
|
||||
};
|
||||
|
||||
export default function StoreBillsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialKind = searchParams.get('kind') === 'WITHDRAW' ? 'WITHDRAW' : '';
|
||||
const initialStoreId = searchParams.get('storeId') || '';
|
||||
@@ -294,7 +296,11 @@ export default function StoreBillsPage() {
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '日期',
|
||||
title: (
|
||||
<Tooltip title="T+1 为账单日(对应昨日核销自然日窗口,不是该账单里最后一笔核销时间);手动提现为申请时间">
|
||||
账单日
|
||||
</Tooltip>
|
||||
),
|
||||
dataIndex: 'date',
|
||||
width: 160,
|
||||
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「账单日」是出账对应的核销自然日(定时任务按昨日窗口生成),不是最后一笔核销时间。
|
||||
{overdueSummary && overdueSummary.overdueCount > 0 ? (
|
||||
<div>
|
||||
<Typography.Text type="danger">
|
||||
@@ -469,7 +476,7 @@ export default function StoreBillsPage() {
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="range" label="日期">
|
||||
<Form.Item name="range" label="账单日">
|
||||
<DatePicker.RangePicker />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
@@ -592,8 +599,22 @@ export default function StoreBillsPage() {
|
||||
columns={[
|
||||
{
|
||||
title: '核销单号',
|
||||
render: (_, r) =>
|
||||
String((r.redeemRecord as { redeemNo?: string })?.redeemNo || '—'),
|
||||
render: (_, r) => {
|
||||
const redeemNo = String(
|
||||
(r.redeemRecord as { redeemNo?: string } | undefined)?.redeemNo || '',
|
||||
);
|
||||
return redeemNo ? (
|
||||
<AdminPrimaryLink
|
||||
onClick={() =>
|
||||
navigate(`/redeem-records?redeemNo=${encodeURIComponent(redeemNo)}`)
|
||||
}
|
||||
>
|
||||
{redeemNo}
|
||||
</AdminPrimaryLink>
|
||||
) : (
|
||||
'—'
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
@@ -662,7 +683,18 @@ export default function StoreBillsPage() {
|
||||
const payout = r.storePayout as
|
||||
| { redeemRecord?: { redeemNo?: string }; payoutAmount?: number }
|
||||
| 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 OssUpload from '../components/OssUpload';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
@@ -560,50 +561,43 @@ export default function SupportTicketsPage() {
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
技术支持
|
||||
</Typography.Title>
|
||||
{settingsButton}
|
||||
<Space>
|
||||
{isSuperAdmin ? (
|
||||
<>
|
||||
<Button
|
||||
disabled={!selectedRowKeys.length}
|
||||
loading={batchCreateSaving}
|
||||
onClick={() => void submitBatchCreateTasks()}
|
||||
>
|
||||
一键创建任务
|
||||
</Button>
|
||||
<Button disabled={!selectedRowKeys.length} onClick={openBatchPublish}>
|
||||
一键发布
|
||||
</Button>
|
||||
<Button disabled={!selectedRowKeys.length} loading={acting} onClick={() => void startBatchPreview()}>
|
||||
批量审核
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!selectedRowKeys.length}
|
||||
onClick={() => {
|
||||
batchStatusForm.setFieldsValue({ status: 'TESTING', rejectReason: '', note: '' });
|
||||
setBatchStatusOpen(true);
|
||||
}}
|
||||
>
|
||||
批量改状态
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<Button type="primary" onClick={() => setCreateOpen(true)}>
|
||||
创建工单
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<AdminListHeader
|
||||
title="技术支持"
|
||||
settings={settingsButton}
|
||||
actions={
|
||||
<>
|
||||
{isSuperAdmin ? (
|
||||
<>
|
||||
<Button
|
||||
disabled={!selectedRowKeys.length}
|
||||
loading={batchCreateSaving}
|
||||
onClick={() => void submitBatchCreateTasks()}
|
||||
>
|
||||
一键创建任务
|
||||
</Button>
|
||||
<Button disabled={!selectedRowKeys.length} onClick={openBatchPublish}>
|
||||
一键发布
|
||||
</Button>
|
||||
<Button disabled={!selectedRowKeys.length} loading={acting} onClick={() => void startBatchPreview()}>
|
||||
批量审核
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!selectedRowKeys.length}
|
||||
onClick={() => {
|
||||
batchStatusForm.setFieldsValue({ status: 'TESTING', rejectReason: '', note: '' });
|
||||
setBatchStatusOpen(true);
|
||||
}}
|
||||
>
|
||||
批量改状态
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<Button type="primary" onClick={() => setCreateOpen(true)}>
|
||||
创建工单
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Form
|
||||
layout="inline"
|
||||
|
||||
@@ -611,7 +611,7 @@ export default function UsersPage() {
|
||||
<Descriptions.Item label="用户编号">{detail.userNo}</Descriptions.Item>
|
||||
<Descriptions.Item label="昵称">{detail.nickname || '—'}</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="验手机时间">
|
||||
{detail.phoneVerifiedAt ? new Date(detail.phoneVerifiedAt).toLocaleString('zh-CN') : '未验证'}
|
||||
</Descriptions.Item>
|
||||
@@ -645,7 +645,7 @@ export default function UsersPage() {
|
||||
<Descriptions.Item label="deviceKey">{detail.deviceKey || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="合并至">
|
||||
{detail.mergedInto
|
||||
? `${detail.mergedInto.userNo} (${detail.mergedInto.phone ? maskPhone(detail.mergedInto.phone) : '无手机'})`
|
||||
? `${detail.mergedInto.userNo} (${detail.mergedInto.phone || '无手机'})`
|
||||
: '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="被合并访客数">{detail.mergedFromCount ?? 0}</Descriptions.Item>
|
||||
|
||||
@@ -94,6 +94,10 @@ export interface DevPlanTaskDto {
|
||||
|
||||
attachmentUrls?: string[] | null;
|
||||
|
||||
versions?: Array<{ id: string; versionNo: string }>;
|
||||
|
||||
versionIds?: string[];
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -166,6 +170,8 @@ export interface UpdateDevPlanTaskInput {
|
||||
|
||||
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 {
|
||||
|
||||
const versions = extras?.versions ?? [];
|
||||
|
||||
return {
|
||||
|
||||
id: String(row.id),
|
||||
@@ -188,6 +194,10 @@ export class DevPlanService {
|
||||
|
||||
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 [names, tickets] = await Promise.all([
|
||||
const [names, tickets, versionMap] = await Promise.all([
|
||||
|
||||
this.loadHqNames(creatorIds),
|
||||
|
||||
@@ -322,6 +332,8 @@ export class DevPlanService {
|
||||
|
||||
: Promise.resolve([]),
|
||||
|
||||
this.loadTaskVersionMap(rows.map((r) => r.id)),
|
||||
|
||||
]);
|
||||
|
||||
const ticketMap = new Map<string, string>(
|
||||
@@ -340,6 +352,8 @@ export class DevPlanService {
|
||||
|
||||
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, {
|
||||
|
||||
creatorName: names.get(String(row.creatorHqAccountId)) ?? null,
|
||||
|
||||
supportTicketNo,
|
||||
|
||||
versions: versionMap.get(String(row.id)) ?? [],
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
@@ -508,6 +526,10 @@ export class DevPlanService {
|
||||
|
||||
await this.prisma.devPlanTask.update({ where: { id }, data });
|
||||
|
||||
if (dto.versionIds !== undefined) {
|
||||
await this.replaceTaskVersions(id, dto.versionIds);
|
||||
}
|
||||
|
||||
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[] }> {
|
||||
|
||||
const links = await this.prisma.devPlanVersionTask.findMany({
|
||||
|
||||
@@ -62,6 +62,11 @@ export class UpdateDevPlanTaskDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
attachmentUrls?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
versionIds?: string[];
|
||||
}
|
||||
|
||||
export class CreateDevPlanVersionDto {
|
||||
|
||||
@@ -54,7 +54,7 @@ export class AdminRedeemService {
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
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 } },
|
||||
coupon: { select: { id: true, couponNo: true, balance: true } },
|
||||
},
|
||||
@@ -77,7 +77,7 @@ export class AdminRedeemService {
|
||||
const record = await this.prisma.redeemRecord.findUnique({
|
||||
where: { id },
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user