feat(intake): add product abandon action to the product list

the product list rows gain an abandon action next to edit/copy/delete for admins and product owners (operations brand / spu / appearance / structure / purchase / merchandiser / planner / space owners matched by name); abandoning sets projectStatus to rejected which makes every workspace read-only and the existing daily todo push already skips rejected products, so abandoned products drop out of daily pushes automatically; the server enforces the same permission via a new POST abandon endpoint. Abandoned rows keep the restore action to come back.
This commit is contained in:
WuYongJie 2026-09-08 12:26:44 +08:00
parent 5754172787
commit df9c0ad67f
3 changed files with 196 additions and 10 deletions

View File

@ -0,0 +1,32 @@
import { NextRequest } from 'next/server'
import { AppError, ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { intakeNonIntegratedProductService } from '@/server/services/intake-non-integrated-product.service'
type RouteContext = {
params: Promise<{ id: string }>
}
export async function POST(request: NextRequest, context: RouteContext) {
return handleRequest(
request,
async (ctx) => {
const { id } = await context.params
const decodedId = decodeURIComponent(id)
try {
const result = await intakeNonIntegratedProductService.abandonProduct(
decodedId,
ctx.user,
)
return jsonSuccess(result, 200, ctx.requestId)
} catch (error) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
}
throw error
}
},
{ requireAuth: true },
)
}

View File

@ -2,7 +2,7 @@
import { useRouter } from "next/navigation";
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { Copy, Pencil, Plus, RotateCcw, Trash2 } from "lucide-react";
import { Ban, Copy, Pencil, Plus, RotateCcw, Trash2 } from "lucide-react";
import Button from "@/components/ui/Button";
import {
CopyProductDialog,
@ -62,8 +62,11 @@ import { getDocumentWorkspaceCountByCategory } from "@/app/intake/product-develo
import { ProductDevelopmentCategoryFilterField } from "@/app/intake/product-development/ProductDevelopmentCategoryFilterField";
import {
displayPerson,
getCurrentUserResponsibleOwnerValues,
getNonIntegratedListPeople,
isAdminUser,
isCurrentUserResponsibleForNonIntegratedProduct,
matchesCurrentUserName,
productMatchesText,
type NonIntegratedListPermissionUser,
} from "@/app/intake/product-development/nonIntegratedProductListLogic";
@ -113,9 +116,23 @@ function createRestoredNonIntegratedProduct(
};
}
function createAbandonedNonIntegratedProduct(
product: NonIntegratedProductRecord,
): NonIntegratedProductRecord {
return {
...product,
projectStatus: "rejected",
openingReference: {
...product.openingReference,
currentStage: "废弃不开",
},
updatedAt: new Date().toISOString(),
};
}
const PENDING_PAGE_SIZE = 50;
const TABLE_ACTION_BUTTON_BASE =
"!h-8 !min-h-8 !w-8 !rounded-apple-md !px-0 !py-0 !shadow-none max-md:!h-7 max-md:!min-h-7 max-md:!w-7 max-md:!min-w-7";
"!h-7 !min-h-7 !w-7 !min-w-7 !rounded-apple-md !px-0 !py-0 !shadow-none max-md:!h-6 max-md:!min-h-6 max-md:!w-6 max-md:!min-w-6";
const TABLE_ACTION_BUTTON_SECONDARY = `${TABLE_ACTION_BUTTON_BASE} !border-zinc-200 !bg-white !text-zinc-500 hover:!border-zinc-300 hover:!bg-zinc-50 hover:!text-zinc-950 focus:!border-zinc-400 disabled:!border-zinc-100 disabled:!bg-zinc-50 disabled:!text-zinc-300`;
const TABLE_ACTION_BUTTON_PRIMARY = `${TABLE_ACTION_BUTTON_BASE} !border-zinc-950 !bg-zinc-950 !text-white hover:!border-zinc-800 hover:!bg-zinc-800 focus:!border-zinc-600 disabled:!border-zinc-200 disabled:!bg-zinc-200 disabled:!text-zinc-400`;
const INTAKE_PRIMARY_BUTTON_CLASS =
@ -622,6 +639,50 @@ export function NonIntegratedPendingDetail({
}
}
async function handleAbandon(product: NonIntegratedProductRecord) {
const productLabel = product.spu?.trim() || "当前产品";
if (
!window.confirm(
`确认废弃「${productLabel}」吗?\n废弃后资料将变为只读,不能继续编辑,且不再触发每日推送提醒。`,
)
) {
return;
}
try {
if (shouldUseSharedNonIntegratedApi) {
const response = await fetch(
`/api/intake/non-integrated/${encodeURIComponent(product.id)}/abandon`,
{ method: "POST", credentials: "same-origin" },
);
const payload = (await response.json().catch(() => null)) as {
success?: boolean;
error?: { message?: string };
} | null;
if (!response.ok || !payload?.success) {
throw new Error(payload?.error?.message || "废弃失败,请稍后再试");
}
} else {
saveNonIntegratedProduct(
createAbandonedNonIntegratedProduct(product),
);
}
const nextProducts = products.map((currentProduct) =>
currentProduct.id === product.id
? { ...currentProduct, projectStatus: "rejected" }
: currentProduct,
) as NonIntegratedProductRecord[];
setProducts(nextProducts);
onProductsChange?.(nextProducts);
} catch (error) {
window.alert(
error instanceof Error ? error.message : "废弃失败,请稍后再试",
);
}
}
function getAvailableCopyBrands(product: NonIntegratedProductRecord) {
const configuredBrands = Array.from(
new Set([
@ -1439,18 +1500,18 @@ export function NonIntegratedPendingDetail({
>
<thead className="bg-apple-bg-primary text-apple-label-secondary">
<tr className="border-b border-apple-separator">
<th className={`min-w-[56px] ${TABLE_HEADER_CELL_CLASS}`}>
<th className={`min-w-[56px] sticky left-0 z-30 ${TABLE_HEADER_CELL_CLASS}`}>
序号
</th>
<th className={`min-w-[96px] ${TABLE_HEADER_CELL_CLASS}`}>
<th className={`min-w-[96px] sticky left-[56px] z-30 ${TABLE_HEADER_CELL_CLASS}`}>
成品预览图
</th>
{isSeriesDevelopmentList ? (
<th className={`min-w-[144px] ${TABLE_HEADER_CELL_CLASS}`}>
<th className={`min-w-[144px] sticky left-[152px] z-30 ${TABLE_HEADER_CELL_CLASS}`}>
系列名称
</th>
) : null}
<th className={`min-w-[144px] ${TABLE_HEADER_CELL_CLASS}`}>
<th className={`min-w-[144px] sticky ${isSeriesDevelopmentList ? "left-[296px]" : "left-[152px]"} z-30 ${TABLE_HEADER_CELL_CLASS}`}>
<SpuLabel />
</th>
{showOpeningProductName ? (
@ -1525,6 +1586,15 @@ export function NonIntegratedPendingDetail({
getDocumentWorkspaceCountByCategory(product);
const isFlowAbandoned =
product.projectStatus === "rejected";
const canAbandonCurrentRow = (
row: NonIntegratedProductRecord,
) =>
row.projectStatus !== "rejected" &&
(isAdminUser(currentUser) ||
isCurrentUserResponsibleForNonIntegratedProduct(
row,
currentUser,
));
const people = getNonIntegratedListPeople(product);
const canEditProduct = canEditNonIntegratedProduct(
product,
@ -1553,12 +1623,12 @@ export function NonIntegratedPendingDetail({
key={product.id}
className="border-b border-apple-separator"
>
<td className="px-3 py-3 max-md:px-2 max-md:py-1.5 text-apple-label-secondary whitespace-nowrap">
<td className="sticky left-0 z-20 bg-apple-bg-primary px-3 py-3 max-md:px-2 max-md:py-1.5 text-apple-label-secondary whitespace-nowrap">
{pageInfo?.sequenceById?.[product.id] ??
orderedFilteredProducts.sequenceById[product.id] ??
(safeCurrentPage - 1) * PENDING_PAGE_SIZE + index + 1}
</td>
<td className="px-3 py-3 max-md:px-2 max-md:py-1.5 whitespace-nowrap">
<td className="sticky left-[56px] z-20 bg-apple-bg-primary px-3 py-3 max-md:px-2 max-md:py-1.5 whitespace-nowrap">
{productPreviewImage ? (
<HoverImagePreview
src={productPreviewImage}
@ -1597,11 +1667,11 @@ export function NonIntegratedPendingDetail({
)}
</td>
{isSeriesDevelopmentList ? (
<td className="px-3 py-3 max-md:px-2 max-md:py-1.5 text-apple-label-primary whitespace-nowrap">
<td className="sticky left-[152px] z-20 bg-apple-bg-primary px-3 py-3 max-md:px-2 max-md:py-1.5 text-apple-label-primary whitespace-nowrap">
{seriesDisplayName}
</td>
) : null}
<td className="px-3 py-3 max-md:px-2 max-md:py-1.5 text-apple-label-primary whitespace-nowrap">
<td className="sticky left-[152px] z-20 bg-apple-bg-primary px-3 py-3 max-md:px-2 max-md:py-1.5 text-apple-label-primary whitespace-nowrap">
<button
type="button"
className="rounded-apple-md px-1 py-0.5 text-left text-apple-blue transition hover:bg-apple-blue/10"
@ -1721,6 +1791,24 @@ export function NonIntegratedPendingDetail({
<span className="sr-only">删除</span>
</Button>
) : null}
{!isFlowAbandoned &&
canEditProduct &&
canAbandonCurrentRow(product) ? (
<Button
variant="plain"
size="small"
aria-label="废弃"
title="废弃"
className={TABLE_ACTION_BUTTON_SECONDARY}
onClick={() => handleAbandon(product)}
>
<Ban
className="h-4 w-4 max-md:h-3.5 max-md:w-3.5"
aria-hidden="true"
/>
<span className="sr-only">废弃</span>
</Button>
) : null}
{isFlowAbandoned && canEditProduct ? (
<Button
variant="plain"

View File

@ -7569,6 +7569,72 @@ class IntakeNonIntegratedProductService {
await recordIntakeProductDeleted({ product: existingRecord, user })
}
async abandonProduct(id: string, user: AuthUser) {
const existingRecord = await this.repository.findActiveById(id)
if (!existingRecord) {
throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '非集成产品不存在')
}
const sourcePayload = isRecord(existingRecord.payload)
? existingRecord.payload
: {}
if (
String(
(sourcePayload as Record<string, unknown>).projectStatus ?? '',
).trim() === 'rejected'
) {
return { abandoned: true }
}
const isAdmin =
user.role === 'ADMIN' || user.role === 'SUPER_ADMIN'
if (!isAdmin) {
const ownerNames = [
'operationsBrandOwner',
'spuOwner',
'appearanceDesigner',
'structureDesigner',
'purchaseOwner',
'merchandiserOwner',
'productPlanner',
'spaceDesigner',
]
.map((key) =>
String(
(sourcePayload as Record<string, unknown>)[key] ?? '',
)
.trim()
.replace(/\s+/g, '')
.toLowerCase(),
)
.filter((value) => value.length > 0)
const userName = String(user.name ?? '')
.trim()
.replace(/\s+/g, '')
.toLowerCase()
const responsible =
userName.length > 0 && ownerNames.includes(userName)
if (!responsible) {
throw new AppError(
ErrorCode.AUTH_PERMISSION_DENIED,
'仅管理员或产品负责人可废弃该产品',
)
}
}
const updatedAt = new Date().toISOString()
await this.repository.update(id, {
payload: toJsonValue({
...sourcePayload,
projectStatus: 'rejected',
updatedAt,
}),
updatedBy: user.id,
} as Prisma.IntakeNonIntegratedProductUpdateInput)
// 同步结构化列表使用的状态派生(payload.projectStatus 为唯一事实来源)
return { abandoned: true }
}
async attachUploadFile(
productId: string,
input: AttachUploadFileInput,