diff --git a/apps/admin-web/src/components/RedeemRecordDetailDescriptions.tsx b/apps/admin-web/src/components/RedeemRecordDetailDescriptions.tsx index 39f7b42..0aa30ca 100644 --- a/apps/admin-web/src/components/RedeemRecordDetailDescriptions.tsx +++ b/apps/admin-web/src/components/RedeemRecordDetailDescriptions.tsx @@ -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 = ¥{Number(detail.settleAmount ?? 0).toFixed(2)} {fmtTime(String(detail.createdAt ?? ''))} {user?.userNo ?? '—'} - {user?.nickname?.trim() || '—'} + + {(() => { + const name = user?.nickname?.trim() || '—'; + const remark = user?.hqRemark?.trim(); + return remark ? `${name}(${remark})` : name; + })()} + {maskUserPhone ? maskPhone(user?.phone) : user?.phone || '—'} diff --git a/apps/admin-web/src/pages/BenefitCouponsPage.tsx b/apps/admin-web/src/pages/BenefitCouponsPage.tsx index 19e4b31..e37c9d5 100644 --- a/apps/admin-web/src/pages/BenefitCouponsPage.tsx +++ b/apps/admin-web/src/pages/BenefitCouponsPage.tsx @@ -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(); - const [filters, setFilters] = useState>({}); + const [filters, setFilters] = useState>(() => { + const init: Record = {}; + 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( @@ -132,6 +139,35 @@ export default function BenefitCouponsPage() { const [redeemDetail, setRedeemDetail] = useState | null>(null); const [redeemDrawerOpen, setRedeemDrawerOpen] = useState(false); const [redeemDetailLoading, setRedeemDetailLoading] = useState(false); + const deepLinkOpenedRef = useRef(false); + + async function openCouponDetail(id: string) { + setDetail(await request(`/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) => ( - { - setDetail(await request(`/admin/benefit/coupons/${row.id}`)); - setDrawerOpen(true); - }} - > + void openCouponDetail(row.id)}> {v} ), @@ -211,10 +242,7 @@ export default function BenefitCouponsPage() { { - setDetail(await request(`/admin/benefit/coupons/${row.id}`)); - setDrawerOpen(true); - }} + onClick={() => void openCouponDetail(row.id)} > 详情 diff --git a/apps/admin-web/src/pages/DevPlanTasksPage.tsx b/apps/admin-web/src/pages/DevPlanTasksPage.tsx index 809aaf4..c3d23de 100644 --- a/apps/admin-web/src/pages/DevPlanTasksPage.tsx +++ b/apps/admin-web/src/pages/DevPlanTasksPage.tsx @@ -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 ? ( + + {row.versions.map((v) => ( + {v.versionNo} + ))} + + ) : ( + '—' + ), + }, { title: '创建人', dataIndex: 'creatorName', width: 90 }, { title: '创建时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { @@ -405,9 +425,21 @@ export default function DevPlanTasksPage() { {editing ? ( - - - + <> + + + + + ({ value: v.id, label: v.versionNo }))} + /> + + > ) : ( + row.user?.id ? ( + navigate('/users', { state: { openUserId: String(row.user!.id) } })} + > + {v || '—'} + + ) : ( + v || '—' + ), + }, { title: '用户昵称', dataIndex: ['user', 'nickname'], - width: 100, - render: (v: string | null | undefined) => v || '—', + width: 160, + render: (_: string | null | undefined, row) => + row.user?.id ? ( + navigate('/users', { state: { openUserId: String(row.user!.id) } })} + > + {formatNicknameWithRemark(row.user)} + + ) : ( + 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 ? ( + + navigate(`/benefit/coupons?couponNo=${encodeURIComponent(v)}`, { + state: row.coupon?.id ? { openCouponId: String(row.coupon.id) } : undefined, + }) + } + > + {v} + + ) : ( + '—' + ), + }, { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '操作', diff --git a/apps/admin-web/src/pages/StoreBillsPage.tsx b/apps/admin-web/src/pages/StoreBillsPage.tsx index 31f9f15..b5b680c 100644 --- a/apps/admin-web/src/pages/StoreBillsPage.tsx +++ b/apps/admin-web/src/pages/StoreBillsPage.tsx @@ -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 = { }; 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: ( + + 账单日 + + ), 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 ? ( @@ -469,7 +476,7 @@ export default function StoreBillsPage() { }))} /> - + @@ -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 ? ( + + navigate(`/redeem-records?redeemNo=${encodeURIComponent(redeemNo)}`) + } + > + {redeemNo} + + ) : ( + '—' + ); + }, }, { 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 ? ( + + navigate(`/redeem-records?redeemNo=${encodeURIComponent(redeemNo)}`) + } + > + {redeemNo} + + ) : ( + '—' + ); }, }, { diff --git a/apps/admin-web/src/pages/SupportTicketsPage.tsx b/apps/admin-web/src/pages/SupportTicketsPage.tsx index 2ff42ee..a94774b 100644 --- a/apps/admin-web/src/pages/SupportTicketsPage.tsx +++ b/apps/admin-web/src/pages/SupportTicketsPage.tsx @@ -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 ( {settingsModal} - - - 技术支持 - - {settingsButton} - - {isSuperAdmin ? ( - <> - void submitBatchCreateTasks()} - > - 一键创建任务 - - - 一键发布 - - void startBatchPreview()}> - 批量审核 - - { - batchStatusForm.setFieldsValue({ status: 'TESTING', rejectReason: '', note: '' }); - setBatchStatusOpen(true); - }} - > - 批量改状态 - - > - ) : null} - setCreateOpen(true)}> - 创建工单 - - - + + {isSuperAdmin ? ( + <> + void submitBatchCreateTasks()} + > + 一键创建任务 + + + 一键发布 + + void startBatchPreview()}> + 批量审核 + + { + batchStatusForm.setFieldsValue({ status: 'TESTING', rejectReason: '', note: '' }); + setBatchStatusOpen(true); + }} + > + 批量改状态 + + > + ) : null} + setCreateOpen(true)}> + 创建工单 + + > + } + /> {detail.userNo} {detail.nickname || '—'} {detail.hqRemark || '—'} - {maskPhone(detail.phone)} + {detail.phone || '—'} {detail.phoneVerifiedAt ? new Date(detail.phoneVerifiedAt).toLocaleString('zh-CN') : '未验证'} @@ -645,7 +645,7 @@ export default function UsersPage() { {detail.deviceKey || '—'} {detail.mergedInto - ? `${detail.mergedInto.userNo} (${detail.mergedInto.phone ? maskPhone(detail.mergedInto.phone) : '无手机'})` + ? `${detail.mergedInto.userNo} (${detail.mergedInto.phone || '无手机'})` : '—'} {detail.mergedFromCount ?? 0} diff --git a/packages/shared-types/src/dev-plan.ts b/packages/shared-types/src/dev-plan.ts index ffef5a9..50990c4 100644 --- a/packages/shared-types/src/dev-plan.ts +++ b/packages/shared-types/src/dev-plan.ts @@ -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[]; + } diff --git a/server/dukang-api/src/modules/dev-plan/dev-plan.service.ts b/server/dukang-api/src/modules/dev-plan/dev-plan.service.ts index e77cafe..af8f22e 100644 --- a/server/dukang-api/src/modules/dev-plan/dev-plan.service.ts +++ b/server/dukang-api/src/modules/dev-plan/dev-plan.service.ts @@ -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( @@ -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>> { + const map = new Map>(); + 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({ diff --git a/server/dukang-api/src/modules/dev-plan/dto/dev-plan.dto.ts b/server/dukang-api/src/modules/dev-plan/dto/dev-plan.dto.ts index 59dcab2..72ac2c4 100644 --- a/server/dukang-api/src/modules/dev-plan/dto/dev-plan.dto.ts +++ b/server/dukang-api/src/modules/dev-plan/dto/dev-plan.dto.ts @@ -62,6 +62,11 @@ export class UpdateDevPlanTaskDto { @IsArray() @IsString({ each: true }) attachmentUrls?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + versionIds?: string[]; } export class CreateDevPlanVersionDto { diff --git a/server/dukang-api/src/modules/ops/admin-redeem.service.ts b/server/dukang-api/src/modules/ops/admin-redeem.service.ts index 30e275e..3782f20 100644 --- a/server/dukang-api/src/modules/ops/admin-redeem.service.ts +++ b/server/dukang-api/src/modules/ops/admin-redeem.service.ts @@ -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,