任务详情支持单图替换改名删除并同步重建下载包

This commit is contained in:
chenpei 2026-08-24 13:05:37 +08:00
parent 2957adfacb
commit 2b615ffc75
12 changed files with 2715 additions and 66 deletions

View File

@ -0,0 +1,140 @@
/**
* 任务-详情单图编辑接口
*
* PATCH - 重命名包内图片(只改文件名,不改扩展名)
* POST - 替换包内图片(body: { uploadId },文件先走受控分片上传)
* DELETE - 删除包内图片(连带重建分组 ZIP 与 all.zip)
*
* 仅任务-详情已完成素材可用;权限走 canManageMaterial
* (上传者 / 托管部门成员 / 系统管理员)。
*/
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import {
TaskDetailAssetEditError,
deleteTaskDetailImage,
renameTaskDetailImage,
replaceTaskDetailImage,
} from '@/lib/task-detail-asset-edit'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const maxDuration = 10800
type RouteParams = { params: { id: string; assetId: string } }
function getSessionUser(session: unknown): { id: string; role?: string | null } | null {
const user = (session as { user?: { id?: string; role?: string } } | null)?.user
if (!user?.id) {
return null
}
return { id: user.id, role: user.role ?? null }
}
function handleEditError(error: unknown) {
if (error instanceof TaskDetailAssetEditError) {
return NextResponse.json(
{ success: false, error: error.message },
{ status: error.statusCode },
)
}
console.error('[materials/asset-edit] error:', error)
return NextResponse.json(
{ success: false, error: '图片编辑失败,请稍后重试' },
{ status: 500 },
)
}
export async function PATCH(
request: NextRequest,
{ params }: RouteParams,
) {
try {
const session = await getServerSession(authOptions)
const operator = getSessionUser(session)
if (!operator) {
return NextResponse.json({ success: false, error: '未登录' }, { status: 401 })
}
const body = await request.json().catch(() => null)
const newFileName = String((body as { newFileName?: unknown } | null)?.newFileName || '').trim()
if (!newFileName) {
return NextResponse.json(
{ success: false, error: '缺少新文件名' },
{ status: 400 },
)
}
const result = await renameTaskDetailImage({
materialId: params.id,
assetId: params.assetId,
operatorUserId: operator.id,
operatorRole: operator.role,
newFileName,
})
return NextResponse.json({ success: true, data: result })
} catch (error) {
return handleEditError(error)
}
}
export async function POST(
request: NextRequest,
{ params }: RouteParams,
) {
try {
const session = await getServerSession(authOptions)
const operator = getSessionUser(session)
if (!operator) {
return NextResponse.json({ success: false, error: '未登录' }, { status: 401 })
}
const body = await request.json().catch(() => null)
const uploadId = String((body as { uploadId?: unknown } | null)?.uploadId || '').trim()
if (!uploadId) {
return NextResponse.json(
{ success: false, error: '缺少替换文件的上传会话,请重新选择文件' },
{ status: 400 },
)
}
const result = await replaceTaskDetailImage({
materialId: params.id,
assetId: params.assetId,
operatorUserId: operator.id,
operatorRole: operator.role,
uploadId,
})
return NextResponse.json({ success: true, data: result })
} catch (error) {
return handleEditError(error)
}
}
export async function DELETE(
_request: NextRequest,
{ params }: RouteParams,
) {
try {
const session = await getServerSession(authOptions)
const operator = getSessionUser(session)
if (!operator) {
return NextResponse.json({ success: false, error: '未登录' }, { status: 401 })
}
const result = await deleteTaskDetailImage({
materialId: params.id,
assetId: params.assetId,
operatorUserId: operator.id,
operatorRole: operator.role,
})
return NextResponse.json({ success: true, data: result })
} catch (error) {
return handleEditError(error)
}
}

View File

@ -20,6 +20,7 @@ import {
canManageMaterial,
type MaterialAuthorizationUser,
} from '@/lib/material-authorization';
import { isProductPackageType } from '@/lib/material-type-mapper';
type AssetRouteSessionUser = MaterialAuthorizationUser & {
department?: string | null;
@ -174,6 +175,7 @@ export async function DELETE(
select: {
id: true,
userId: true,
materialType: true,
previewUrl: true,
zipFilePath: true,
zipFileName: true,
@ -196,6 +198,18 @@ export async function DELETE(
);
}
// 任务-详情的资产是打包产物:只删记录会让 ZIP 里仍保留旧图。
// 单图删除必须走 /assets/[assetId] 接口(会重建分组 ZIP 和 all.zip)。
if (isProductPackageType(material.materialType)) {
return NextResponse.json(
{
success: false,
error: '任务-详情素材请使用单图编辑接口删除图片,直接删除会导致下载包与预览不一致',
},
{ status: 409 },
);
}
// 验证资产是否属于该素材
const asset = await prisma.materialAsset.findUnique({
where: { id: assetId },

View File

@ -12,6 +12,7 @@ import {
} from '@/lib/department-permission';
import { isProductPackageType, is3DOriginalType, needsAssetsList } from '@/lib/material-type-mapper';
import { deriveTaskDetailIsFullSet } from '@/lib/task-detail-full-set';
import { canManageMaterial } from '@/lib/material-authorization';
import { detectFileKindFromName, getCertificateReportFileKindByAssetType } from '@/lib/certificate-report';
import { DownloadErrorCode } from '@/lib/download-errors';
import { getVideoPreviewPolicyFields } from '@/lib/video-download-policy';
@ -323,6 +324,16 @@ export async function GET(request: NextRequest) {
canDelete: Boolean(currentUser && isSystemAdmin(currentUser.role)),
};
// 任务-详情单图编辑(替换/改名/删除)与元数据编辑权限口径不同:
// 走 canManageMaterial,托管部门的成员也能维护本部门素材。
let canManageTaskDetailAssets = false;
if (userId && currentUser && isProductPackageType(material.materialType as unknown as string)) {
canManageTaskDetailAssets = await canManageMaterial(
{ id: userId, role: currentUser.role, isSuperAdmin: currentUser.isSuperAdmin },
{ id: material.id, userId: material.userId } as any,
);
}
const response: any = {
...material,
zipFileName: getSourceDisplayName(material.zipFileName || material.zipFilePath),
@ -336,6 +347,7 @@ export async function GET(request: NextRequest) {
isLiked,
isFavorited,
capabilities,
canManageTaskDetailAssets,
assets: needsAssetsList(material.materialType as unknown as string) ? assets : undefined,
canDownloadSource: needsAssetsList(material.materialType as unknown as string) ? canAccessSource : undefined,
isFullSet: deriveTaskDetailIsFullSet(

View File

@ -29,6 +29,8 @@ import { parseApiError, parseFetchError } from '@/lib/error-handler'
import { getCertificateReportDisplayTitle, isCertificateReportMaterialType } from '@/lib/certificate-report'
import { isVideoMaterialType } from '@/lib/video-material-types'
import { buildPublicMaterialShareUrl } from '@/lib/public-app-url'
import { chunkUpload } from '@/lib/chunk-upload'
import { TASK_DETAIL_UPLOAD_LIMITS } from '@/lib/task-detail-upload-policy'
interface Material {
id: string
@ -63,6 +65,7 @@ interface Material {
taskFetchedAt?: string // 产品全案被获取时间
isFullSet?: boolean
canDownloadSource?: boolean // 产品全案 PSD 下载权限(参考"详情模板"类型)
canManageTaskDetailAssets?: boolean // 任务-详情单图编辑权限(上传者/托管部门/管理员)
sourceFileUrl?: string | null
assets?: Array<{
id: string
@ -310,6 +313,21 @@ export default function SharePage() {
const { confirmAction, confirmActionDialog } = useConfirmActionDialog()
const pageRef = useRef<HTMLDivElement>(null)
// 任务-详情单图编辑状态
const [taskDetailEditingAssetId, setTaskDetailEditingAssetId] = useState<string | null>(null)
const [replaceUploadProgress, setReplaceUploadProgress] = useState<number | null>(null)
const replaceInputRef = useRef<HTMLInputElement | null>(null)
const replaceTargetAssetIdRef = useRef<string | null>(null)
const [renameDialogState, setRenameDialogState] = useState<{
assetId: string
currentName: string
baseName: string
extension: string
value: string
error: string | null
saving: boolean
} | null>(null)
useGSAP(() => {
if (loading || !material || typeof window.matchMedia !== 'function') return
if (!pageRef.current?.querySelector('[data-detail-preview], [data-detail-meta]')) return
@ -907,6 +925,149 @@ export default function SharePage() {
})
}
// ---- 任务-详情单图编辑 ----
const handleTaskDetailReplaceRequest = (asset: { id: string }) => {
replaceTargetAssetIdRef.current = asset.id
const input = replaceInputRef.current
if (input) {
input.value = ''
input.click()
}
}
const uploadAndReplaceTaskDetailImage = async (file: File) => {
const targetAssetId = replaceTargetAssetIdRef.current
if (!targetAssetId || !material) return
if (file.size > TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes) {
showToast(
`替换图片不能超过 ${Math.round(TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes / 1024 / 1024)}MB,请先压缩`,
{ variant: 'error' },
)
replaceTargetAssetIdRef.current = null
return
}
setTaskDetailEditingAssetId(targetAssetId)
setReplaceUploadProgress(0)
try {
const result = await chunkUpload(file, {}, {
skipComplete: true,
onProgress: (progress) => setReplaceUploadProgress(progress),
})
const uploadId = (result as { uploadId?: string } | null)?.uploadId
if (!uploadId) {
throw new Error('上传未完成,请重试')
}
showToast('图片已上传,正在重新打包素材…', { variant: 'success' })
const response = await fetch(`/api/materials/${material.id}/assets/${targetAssetId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uploadId }),
})
const data = await response.json().catch(() => null)
if (!response.ok || !data?.success) {
throw new Error(data?.error || '替换失败,请稍后重试')
}
showToast('图片已替换,预览和下载包已同步更新', { variant: 'success' })
await fetchMaterial()
} catch (error) {
showToast(error instanceof Error ? error.message : '替换失败,请稍后重试', { variant: 'error' })
} finally {
setTaskDetailEditingAssetId(null)
setReplaceUploadProgress(null)
replaceTargetAssetIdRef.current = null
}
}
const openTaskDetailRenameDialog = (asset: { id: string; fileName?: string }) => {
const currentName = asset.fileName || ''
const dotIndex = currentName.lastIndexOf('.')
const baseName = dotIndex > 0 ? currentName.slice(0, dotIndex) : currentName
const extension = dotIndex > 0 ? currentName.slice(dotIndex) : ''
setRenameDialogState({
assetId: asset.id,
currentName,
baseName,
extension,
value: baseName,
error: null,
saving: false,
})
}
const submitTaskDetailRename = async () => {
if (!renameDialogState || !material) return
const nextBaseName = renameDialogState.value.trim()
if (!nextBaseName) {
setRenameDialogState((prev) => prev ? { ...prev, error: '文件名不能为空' } : prev)
return
}
if (/[\\/:*?"<>|]/.test(nextBaseName)) {
setRenameDialogState((prev) => prev ? { ...prev, error: '文件名不能包含 \\ / : * ? " < > | 字符' } : prev)
return
}
if (nextBaseName === renameDialogState.baseName) {
setRenameDialogState(null)
return
}
setRenameDialogState((prev) => prev ? { ...prev, saving: true, error: null } : prev)
try {
const response = await fetch(
`/api/materials/${material.id}/assets/${renameDialogState.assetId}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ newFileName: nextBaseName + renameDialogState.extension }),
},
)
const data = await response.json().catch(() => null)
if (!response.ok || !data?.success) {
throw new Error(data?.error || '重命名失败,请稍后重试')
}
showToast('文件名已更新', { variant: 'success' })
setRenameDialogState(null)
await fetchMaterial()
} catch (error) {
setRenameDialogState((prev) => prev
? { ...prev, saving: false, error: error instanceof Error ? error.message : '重命名失败' }
: prev)
}
}
const handleTaskDetailDeleteRequest = async (asset: { id: string; fileName?: string }) => {
if (!material) return
await confirmAction({
title: '删除这张图片?',
description: `「${asset.fileName || '未命名图片'}」将从素材和下载包中移除。`,
confirmLabel: '删除图片',
loadingLabel: '正在删除…',
tone: 'danger',
action: async () => {
setTaskDetailEditingAssetId(asset.id)
try {
const response = await fetch(`/api/materials/${material.id}/assets/${asset.id}`, {
method: 'DELETE',
})
const data = await response.json().catch(() => null)
if (!response.ok || !data?.success) {
throw new Error(data?.error || '删除失败,请稍后重试')
}
showToast('图片已删除,下载包已同步更新', { variant: 'success' })
await fetchMaterial()
} catch (error) {
showToast(error instanceof Error ? error.message : '删除失败,请稍后重试', { variant: 'error' })
} finally {
setTaskDetailEditingAssetId(null)
}
},
})
}
if (loading) {
return (
<div className="min-h-screen bg-[#fafafa] text-[#1a1c1d]">
@ -1007,6 +1168,11 @@ export default function SharePage() {
}}
canDownloadSource={material.canDownloadSource ?? false} // 产品全案PSD下载权限参考"详情模板"类型的 canDownloadSource 权限
showToast={showToast}
canManageAssets={Boolean(material.canManageTaskDetailAssets)}
editingAssetId={taskDetailEditingAssetId}
onRequestReplace={handleTaskDetailReplaceRequest}
onRequestRename={openTaskDetailRenameDialog}
onRequestDelete={(asset) => void handleTaskDetailDeleteRequest(asset)}
onDownload={async (type, assetId) => {
try {
let directDownloadUrl = `/api/materials/download?id=${material.id}&type=${type}`
@ -1565,9 +1731,93 @@ export default function SharePage() {
/>
)}
{/* 任务-详情:替换图片的文件选择器 */}
<input
ref={replaceInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0]
if (file) {
void uploadAndReplaceTaskDetailImage(file)
}
event.target.value = ''
}}
/>
{/* 任务-详情:重命名弹窗 */}
{renameDialogState && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-[#1a1c1d]/30 px-4"
role="dialog"
aria-modal="true"
aria-label="重命名图片"
onClick={() => {
if (!renameDialogState.saving) setRenameDialogState(null)
}}
>
<div
className="w-full max-w-md rounded-[12px] border border-[#e7e8ea] bg-white p-6"
onClick={(event) => event.stopPropagation()}
>
<h3 className="text-[15px] font-semibold tracking-[-0.03em] text-[#1a1c1d]">重命名图片</h3>
<p className="mt-1 text-[12px] text-[#86898e]">
只修改文件名,扩展名保持不变;保存后会重新打包下载文件。
</p>
<div className="mt-4 flex items-stretch">
<input
autoFocus
type="text"
value={renameDialogState.value}
disabled={renameDialogState.saving}
onChange={(event) => {
const rawValue = event.target.value.replace(/[\\/:*?"<>|]/g, '')
setRenameDialogState((prev) => prev ? { ...prev, value: rawValue, error: null } : prev)
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && !renameDialogState.saving) {
void submitTaskDetailRename()
}
}}
className="min-h-10 w-full rounded-l-[8px] border border-r-0 border-[#e7e8ea] bg-white px-3 text-[13px] text-[#1a1c1d] focus:border-[#1a1c1d] focus:outline-none"
aria-label="新文件名"
/>
<span className="flex min-h-10 items-center rounded-r-[8px] border border-[#e7e8ea] bg-[#fafafa] px-2.5 text-[13px] text-[#86898e]">
{renameDialogState.extension || '(无扩展名)'}
</span>
</div>
{renameDialogState.error && (
<p className="mt-2 text-[12px] text-[#b42318]" role="alert">{renameDialogState.error}</p>
)}
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
disabled={renameDialogState.saving}
onClick={() => setRenameDialogState(null)}
className="inline-flex h-9 items-center rounded-[8px] border border-[#e7e8ea] bg-white px-4 text-[13px] font-medium text-[#1a1c1d] transition-colors hover:bg-[#fafafa] disabled:opacity-50"
>
取消
</button>
<button
type="button"
disabled={renameDialogState.saving}
onClick={() => void submitTaskDetailRename()}
className="inline-flex h-9 items-center rounded-[8px] bg-[#1a1c1d] px-4 text-[13px] font-medium text-white transition-colors hover:bg-[#2a2d2f] disabled:opacity-50"
>
{renameDialogState.saving ? '正在保存…' : '保存并重打包'}
</button>
</div>
</div>
</div>
)}
{/* 统一的Toast提示 */}
{toast && toast.show && (
<ToastBubble message={toast.message} variant={toast.variant} />
<ToastBubble message={replaceUploadProgress !== null
? `正在上传替换图片… ${replaceUploadProgress}%`
: toast.message}
variant={toast.variant} />
)}
{confirmActionDialog}
</div>

View File

@ -44,6 +44,96 @@ interface ProductPackageDetailProps {
onDownload: (type: 'all' | 'main' | 'sku' | 'detail' | 'psd', assetId?: string) => Promise<void>;
onBack?: () => void;
showToast?: (message: string, options?: { variant?: 'success' | 'error' }) => void;
/** 单图编辑权限(上传者 / 托管部门成员 / 系统管理员),由服务端判定 */
canManageAssets?: boolean;
/** 正在编辑处理的资产 id,用于禁用与遮罩 */
editingAssetId?: string | null;
onRequestReplace?: (asset: Asset) => void;
onRequestRename?: (asset: Asset) => void;
onRequestDelete?: (asset: Asset) => void;
}
function AssetEditMenu({
asset,
busy,
disabled,
onRequestReplace,
onRequestRename,
onRequestDelete,
}: {
asset: Asset;
busy: boolean;
disabled: boolean;
onRequestReplace: (asset: Asset) => void;
onRequestRename: (asset: Asset) => void;
onRequestDelete: (asset: Asset) => void;
}) {
const [open, setOpen] = useState(false);
if (disabled) {
return busy ? (
<span className="absolute right-1 top-1 z-10 rounded-[6px] bg-white/90 px-1.5 py-0.5 text-[11px] text-[#6b7075]">
处理中…
</span>
) : null;
}
return (
<span className="absolute right-1 top-1 z-10">
<button
type="button"
aria-label={`编辑图片 ${asset.fileName}`}
className="flex h-6 w-6 items-center justify-center rounded-[6px] border border-[#e7e8ea] bg-white/95 text-[#1a1c1d] opacity-0 transition-opacity hover:border-[#1a1c1d] group-hover:opacity-100 focus-visible:opacity-100"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setOpen((value) => !value);
}}
>
<svg viewBox="0 0 16 16" className="h-3.5 w-3.5" fill="currentColor" aria-hidden="true">
<circle cx="8" cy="3.5" r="1.4" />
<circle cx="8" cy="8" r="1.4" />
<circle cx="8" cy="12.5" r="1.4" />
</svg>
</button>
{open && (
<>
<span
className="fixed inset-0 z-20 cursor-default"
onClick={(event) => {
event.stopPropagation();
setOpen(false);
}}
onMouseDown={(event) => {
event.preventDefault();
}}
/>
<span className="absolute right-0 top-7 z-30 block w-28 overflow-hidden rounded-[8px] border border-[#e7e8ea] bg-white py-1">
{[
{ label: '替换图片', action: () => { setOpen(false); onRequestReplace(asset); } },
{ label: '重命名', action: () => { setOpen(false); onRequestRename(asset); } },
{ label: '删除图片', action: () => { setOpen(false); onRequestDelete(asset); }, danger: true },
].map((item) => (
<button
key={item.label}
type="button"
className={`block w-full px-3 py-1.5 text-left text-[12px] tracking-[-0.01em] hover:bg-[#fafafa] ${
item.danger ? 'text-[#b42318]' : 'text-[#1a1c1d]'
}`}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
item.action();
}}
>
{item.label}
</button>
))}
</span>
</>
)}
</span>
);
}
export default function ProductPackageDetail({
@ -52,6 +142,11 @@ export default function ProductPackageDetail({
onDownload,
onBack,
showToast,
canManageAssets = false,
editingAssetId = null,
onRequestReplace,
onRequestRename,
onRequestDelete,
}: ProductPackageDetailProps) {
// 当前预览的图片类型和索引
const [previewType, setPreviewType] = useState<'main' | 'sku'>('main');
@ -165,8 +260,12 @@ export default function ProductPackageDetail({
<h4 className={detailSectionTitleClass}>主图 ({mainImages.length})</h4>
<div className="flex flex-wrap gap-2">
{mainImages.map((img, index) => (
<button
<div
key={img.id}
className="group relative flex-shrink-0"
style={{ width: '84px', height: '84px' }}
>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
@ -184,12 +283,11 @@ export default function ProductPackageDetail({
// 阻止默认的鼠标按下行为
e.preventDefault();
}}
className={`relative flex-shrink-0 overflow-hidden rounded-[8px] ${
className={`absolute inset-0 overflow-hidden rounded-[8px] ${
previewType === 'main' && index === currentMainIndex
? detailThumbActiveClass
: detailThumbIdleClass
}`}
style={{ width: '84px', height: '84px' }}
type="button"
>
<img
@ -205,6 +303,22 @@ export default function ProductPackageDetail({
</div>
)}
</button>
{canManageAssets && onRequestReplace && onRequestRename && onRequestDelete && (
<>
<AssetEditMenu
asset={img}
busy={editingAssetId === img.id}
disabled={Boolean(editingAssetId)}
onRequestReplace={onRequestReplace}
onRequestRename={onRequestRename}
onRequestDelete={onRequestDelete}
/>
{editingAssetId === img.id && (
<span className="pointer-events-none absolute inset-0 z-0 animate-pulse rounded-[8px] border border-[#1a1c1d]/30 bg-white/60" />
)}
</>
)}
</div>
))}
</div>
</div>
@ -216,7 +330,7 @@ export default function ProductPackageDetail({
<h4 className={detailSectionTitleClass}>详情图 ({detailImages.length})</h4>
<div className="space-y-0">
{detailImages.map((img, index) => (
<div key={img.id} className="relative">
<div key={img.id} className="group relative">
<img
src={img.fileUrl}
alt={`详情图 ${index + 1}`}
@ -226,6 +340,23 @@ export default function ProductPackageDetail({
<div className="absolute left-2 top-2 bg-[#1a1c1d] px-2 py-1 text-[11px] text-white">
{index + 1}
</div>
{canManageAssets && onRequestReplace && onRequestRename && onRequestDelete && (
<>
<AssetEditMenu
asset={img}
busy={editingAssetId === img.id}
disabled={Boolean(editingAssetId)}
onRequestReplace={onRequestReplace}
onRequestRename={onRequestRename}
onRequestDelete={onRequestDelete}
/>
{editingAssetId === img.id && (
<span className="pointer-events-none absolute inset-x-0 top-0 z-0 flex items-center justify-center bg-white/60 py-2 text-[12px] text-[#6b7075]">
正在重新打包,请稍候…
</span>
)}
</>
)}
</div>
))}
</div>
@ -278,8 +409,8 @@ export default function ProductPackageDetail({
<h4 className={detailSectionTitleClass}>SKU图 ({skuImages.length})</h4>
<div className="grid grid-cols-4 gap-2">
{skuImages.map((img, index) => (
<div key={img.id} className="group relative aspect-square w-full">
<button
key={img.id}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
@ -287,7 +418,7 @@ export default function ProductPackageDetail({
setCurrentSkuIndex(index);
setAutoPlayEnabled(false); // 手动切换时暂停轮播
}}
className={`relative aspect-square w-full overflow-hidden rounded-[8px] ${
className={`absolute inset-0 overflow-hidden rounded-[8px] ${
previewType === 'sku' && index === currentSkuIndex
? detailThumbActiveClass
: detailThumbIdleClass
@ -307,6 +438,22 @@ export default function ProductPackageDetail({
</div>
)}
</button>
{canManageAssets && onRequestReplace && onRequestRename && onRequestDelete && (
<>
<AssetEditMenu
asset={img}
busy={editingAssetId === img.id}
disabled={Boolean(editingAssetId)}
onRequestReplace={onRequestReplace}
onRequestRename={onRequestRename}
onRequestDelete={onRequestDelete}
/>
{editingAssetId === img.id && (
<span className="pointer-events-none absolute inset-0 z-0 animate-pulse rounded-[8px] border border-[#1a1c1d]/30 bg-white/60" />
)}
</>
)}
</div>
))}
</div>
</div>

View File

@ -109,6 +109,17 @@ describe('material assets route authorization', () => {
;(getServerSession as jest.Mock).mockResolvedValue({
user: { id: 'user-1', role: 'USER', department: '设计部' },
})
// 该接口只服务非打包类素材;任务-详情走单图编辑接口
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
id: 'material-1',
userId: 'user-1',
materialType: '视频',
previewUrl: '',
zipFilePath: '',
zipFileName: '',
zipFileSize: BigInt(0),
displayImages: [],
})
;(prisma.materialAsset.findUnique as jest.Mock).mockResolvedValue({
id: 'asset-1',
materialId: 'material-1',
@ -147,4 +158,19 @@ describe('material assets route authorization', () => {
fileCleanupError: null,
})
})
test('任务-详情素材不得通过该接口直删资产(会导致 ZIP 与预览不一致)', async () => {
;(getServerSession as jest.Mock).mockResolvedValue({
user: { id: 'user-1', role: 'USER', department: '设计部' },
})
const response = await DELETE({
url: 'http://localhost/api/materials/material-1/assets?assetId=asset-1',
} as any, { params: { id: 'material-1' } })
const body = await response.json()
expect(response.status).toBe(409)
expect(body.error).toContain('单图编辑接口')
expect(prisma.materialAsset.findUnique).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,118 @@
/** @jest-environment node */
import { getServerSession } from 'next-auth'
import {
DELETE,
PATCH,
POST,
} from '@/app/api/materials/[id]/assets/[assetId]/route'
import {
deleteTaskDetailImage,
renameTaskDetailImage,
replaceTaskDetailImage,
} from '@/lib/task-detail-asset-edit'
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/task-detail-asset-edit', () => ({
TaskDetailAssetEditError: class TaskDetailAssetEditError extends Error {
statusCode: number
constructor(message: string, statusCode = 400) {
super(message)
this.statusCode = statusCode
}
},
deleteTaskDetailImage: jest.fn(),
renameTaskDetailImage: jest.fn(),
replaceTaskDetailImage: jest.fn(),
}))
const mockedDelete = deleteTaskDetailImage as jest.Mock
const mockedRename = renameTaskDetailImage as jest.Mock
const mockedReplace = replaceTaskDetailImage as jest.Mock
describe('任务-详情单图编辑路由', () => {
beforeEach(() => {
jest.clearAllMocks()
})
test('未登录返回 401,不触发编辑逻辑', async () => {
;(getServerSession as jest.Mock).mockResolvedValue(null)
for (const response of [
await PATCH({ json: async () => ({ newFileName: 'x' }) } as any, { params: { id: 'm1', assetId: 'a1' } }),
await POST({ json: async () => ({ uploadId: 'u1' }) } as any, { params: { id: 'm1', assetId: 'a1' } }),
await DELETE({} as any, { params: { id: 'm1', assetId: 'a1' } }),
]) {
expect(response.status).toBe(401)
}
expect(mockedRename).not.toHaveBeenCalled()
expect(mockedReplace).not.toHaveBeenCalled()
expect(mockedDelete).not.toHaveBeenCalled()
})
test('PATCH 缺少新文件名返回 400', async () => {
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'u1' } })
const response = await PATCH({ json: async () => ({}) } as any, { params: { id: 'm1', assetId: 'a1' } })
expect(response.status).toBe(400)
expect(mockedRename).not.toHaveBeenCalled()
})
test('POST 缺少 uploadId 返回 400', async () => {
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'u1' } })
const response = await POST({ json: async () => ({}) } as any, { params: { id: 'm1', assetId: 'a1' } })
expect(response.status).toBe(400)
expect(mockedReplace).not.toHaveBeenCalled()
})
test('登录用户正常调用并透传参数', async () => {
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'u1', role: 'USER' } })
mockedRename.mockResolvedValue({ success: true })
mockedReplace.mockResolvedValue({ success: true })
mockedDelete.mockResolvedValue({ success: true })
await PATCH(
{ json: async () => ({ newFileName: '新名字' }) } as any,
{ params: { id: 'm1', assetId: 'a1' } },
)
expect(mockedRename).toHaveBeenCalledWith({
materialId: 'm1',
assetId: 'a1',
operatorUserId: 'u1',
operatorRole: 'USER',
newFileName: '新名字',
})
await POST(
{ json: async () => ({ uploadId: 'upload-1' }) } as any,
{ params: { id: 'm1', assetId: 'a2' } },
)
expect(mockedReplace).toHaveBeenCalledWith({
materialId: 'm1',
assetId: 'a2',
operatorUserId: 'u1',
operatorRole: 'USER',
uploadId: 'upload-1',
})
await DELETE({} as any, { params: { id: 'm1', assetId: 'a3' } })
expect(mockedDelete).toHaveBeenCalledWith({
materialId: 'm1',
assetId: 'a3',
operatorUserId: 'u1',
operatorRole: 'USER',
})
})
test('权限类业务错误透传状态码与文案', async () => {
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'intruder' } })
const { TaskDetailAssetEditError: MockedError } = await import('@/lib/task-detail-asset-edit')
mockedDelete.mockRejectedValue(new (MockedError as any)('您没有权限编辑此素材', 403))
const response = await DELETE({} as any, { params: { id: 'm1', assetId: 'a1' } })
expect(response.status).toBe(403)
const body = await response.json()
expect(body.error).toBe('您没有权限编辑此素材')
})
})

View File

@ -0,0 +1,114 @@
/** @jest-environment node */
import {
buildServerPreviewFileName,
groupPreviewRowsIntoRuns,
matchRunsToZipEntries,
parseTaskDetailPreviewFileName,
} from '@/lib/task-detail-asset-edit'
function row(id: string, fileName: string, sortOrder = 0): any {
return { id, assetType: 'main-preview', fileUrl: `s3://${id}`, fileName, sortOrder, skuCode: null }
}
describe('任务-详情预览名解析', () => {
test('客户端单图命名提取来源基础名', () => {
expect(parseTaskDetailPreviewFileName('IMG_1001.webp')).toEqual({
label: 'IMG_1001',
sliceIndex: null,
})
})
test('客户端切片命名', () => {
expect(parseTaskDetailPreviewFileName('长图-001.webp')).toEqual({
label: '长图',
sliceIndex: 1,
})
})
test('服务端生成命名携带来源锚', () => {
const name = buildServerPreviewFileName('IMG_2002', 3)
expect(parseTaskDetailPreviewFileName(name)).toEqual({
label: 'IMG_2002',
sliceIndex: null,
})
})
test('非 webp 返回 null', () => {
expect(parseTaskDetailPreviewFileName('photo.jpg')).toBeNull()
})
})
describe('任务-详情预览行切分', () => {
test('相邻同名但独立的单预览保持独立 run', () => {
const runs = groupPreviewRowsIntoRuns([
row('a', 'cover.webp', 0),
row('b', 'cover.webp', 1),
])
expect(runs).not.toBeNull()
expect(runs!.length).toBe(2)
})
test('连续切片合并为一个 run,序号重置后开启新 run', () => {
const runs = groupPreviewRowsIntoRuns([
row('a', 'detail-001.webp', 0),
row('b', 'detail-002.webp', 1),
row('c', 'detail-001.webp', 2),
row('d', 'detail-002.webp', 3),
])
expect(runs).not.toBeNull()
expect(runs!.length).toBe(2)
expect(runs![0].rows.map((item: any) => item.id)).toEqual(['a', 'b'])
expect(runs![1].rows.map((item: any) => item.id)).toEqual(['c', 'd'])
})
test('出现无法解析的名字时整体放弃切分', () => {
expect(groupPreviewRowsIntoRuns([row('a', 'unknown.png')])).toBeNull()
})
})
describe('run 与 ZIP 条目的名字匹配', () => {
const entryNames = ['IMG_A.jpg', 'IMG_B.png', 'IMG_C.jpg']
test('按基础名唯一命中(顺序无关)', () => {
const runs = groupPreviewRowsIntoRuns([
row('a', 'IMG_C.webp', 0),
row('b', 'IMG_A.webp', 1),
row('c', 'IMG_B.webp', 2),
])!
expect(matchRunsToZipEntries(runs, entryNames)).toEqual([2, 0, 1])
})
test('单个无锚 run 与剩余条目一一配对', () => {
const srvName = buildServerPreviewFileName('IMG_B', 1)
const runs = groupPreviewRowsIntoRuns([
row('a', 'IMG_A.webp', 0),
row('b', srvName, 1),
row('c', 'IMG_C.webp', 2),
])!
expect(matchRunsToZipEntries(runs, entryNames)).toEqual([0, 1, 2])
})
test('两个以上无锚 run 拒绝编辑', () => {
// 历史服务端兜底命名(旧格式,不带来源锚)无法确认对应关系
const runs = groupPreviewRowsIntoRuns([
row('a', 'main-preview-1736000000000-0-aaa0000.webp', 0),
row('b', 'main-preview-1736000000000-1-bbb1111.webp', 1),
])!
expect(() => matchRunsToZipEntries(runs, ['x.jpg', 'y.png'])).toThrow()
})
test('数量不一致直接拒绝', () => {
const runs = groupPreviewRowsIntoRuns([row('a', 'IMG_A.webp')])!
expect(() => matchRunsToZipEntries(runs, entryNames)).toThrow()
})
test('同名条目导致歧义时拒绝', () => {
// 预览行只有一份 cover,但包里有两个同名条目
const runs = groupPreviewRowsIntoRuns([
row('a', 'cover.webp', 0),
row('b', 'other.webp', 1),
])!
expect(() => matchRunsToZipEntries(runs, ['cover.jpg', 'COVER.png'])).toThrow()
})
})

View File

@ -0,0 +1,101 @@
/** @jest-environment node */
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises'
import os from 'os'
import path from 'path'
import { createZipFromFiles } from '@/lib/task-material-processor'
import { deleteStoredUploadFileFromRemote, syncStoredUploadFileToRemote } from '@/lib/remote-upload-sync'
import { extractZipEntriesToDirectory, listZipEntries } from '@/lib/zip-entry-reader'
import { groupPreviewRowsIntoRuns, matchRunsToZipEntries } from '@/lib/task-detail-asset-edit'
jest.mock('@/lib/prisma', () => ({ prisma: {} }))
jest.mock('@/lib/s3', () => ({ deleteFromS3: jest.fn() }))
jest.mock('@/lib/public-display', () => ({
buildMaterialPublicDisplayPath: jest.fn(),
uploadPublicDisplayToS3: jest.fn(),
}))
jest.mock('@/lib/image-processor', () => ({ compressImage: jest.fn() }))
jest.mock('@/lib/remote-upload-sync', () => ({
deleteStoredUploadFileFromRemote: jest.fn().mockResolvedValue(undefined),
syncStoredUploadFileToRemote: jest.fn(),
}))
jest.mock('@/lib/material-upload-merge', () => ({
acquireUploadMergeDiskReservation: jest.fn().mockResolvedValue(() => undefined),
}))
/**
* 生产链路(archiver)并发打包不保证中央目录顺序等于传入顺序。
* 单图编辑因此不依赖位置映射,而是按"来源文件名"锚定匹配。
* 这里用真实打包产物验证:条目顺序无论怎样,名字匹配都能对上。
*/
describe('生产打包链路的单图编辑前提', () => {
let uploadRoot: string
let previousUploadDir: string | undefined
beforeEach(async () => {
previousUploadDir = process.env.UPLOAD_DIR
uploadRoot = await mkdtemp(path.join(os.tmpdir(), 'entry-order-'))
process.env.UPLOAD_DIR = uploadRoot
})
afterEach(async () => {
if (previousUploadDir === undefined) {
delete process.env.UPLOAD_DIR
} else {
process.env.UPLOAD_DIR = previousUploadDir
}
await rm(uploadRoot, { recursive: true, force: true })
})
function row(id: string, fileName: string, sortOrder: number): any {
return { id, assetType: 'main-preview', fileUrl: `s3://${id}`, fileName, sortOrder, skuCode: null }
}
test('条目内容完整解压;预览行与条目按名字正确配对(与顺序无关)', async () => {
const names = ['z-last.jpg', 'a-first.jpg', '中图.png', 'm-middle.jpg']
const files: Array<{ path: string; name: string; size: number }> = []
const sourceDir = path.join(uploadRoot, 'src')
await mkdir(sourceDir, { recursive: true })
for (let index = 0; index < names.length; index += 1) {
const name = names[index]
const filePath = path.join(sourceDir, `file-${index}.bin`)
const bytes = Buffer.from(`${name}-${index}`)
await writeFile(filePath, bytes)
files.push({ path: filePath, name, size: bytes.length })
}
const result = await createZipFromFiles(
files,
'material-order-check',
'MODEL',
'任务-详情',
'主图',
'作者',
'店铺',
)
const zipPath = path.join(uploadRoot, 'materials', result.fileName)
// 解压:每个条目内容完整
const destination = path.join(uploadRoot, 'extracted')
const extracted = await extractZipEntriesToDirectory(zipPath, destination)
expect(extracted.map((item) => item.entryName).sort()).toEqual([...names].sort())
const byName = new Map(extracted.map((item) => [item.entryName, item]))
expect((await readFile(byName.get('中图.png')!.filePath)).toString()).toBe('中图.png-2')
// 匹配:即使中央目录顺序被打乱,预览行仍能按基础名找到自己的条目
const entries = await listZipEntries(zipPath)
const entryNames = entries.map((entry) => entry.name)
const runs = groupPreviewRowsIntoRuns([
row('p0', 'z-last.webp', 0),
row('p1', 'a-first.webp', 1),
row('p2', '中图.webp', 2),
row('p3', 'm-middle.webp', 3),
])!
const assigned = matchRunsToZipEntries(runs, entryNames)
expect(entryNames[assigned[0]]).toBe('z-last.jpg')
expect(entryNames[assigned[1]]).toBe('a-first.jpg')
expect(entryNames[assigned[2]]).toBe('中图.png')
expect(entryNames[assigned[3]]).toBe('m-middle.jpg')
})
})

View File

@ -0,0 +1,122 @@
/** @jest-environment node */
import AdmZip from 'adm-zip'
import { mkdtemp, readFile, readdir, rm } from 'fs/promises'
import { tmpdir } from 'os'
import path from 'path'
import {
createTaskDetailEditWorkspace,
extractZipEntriesToDirectory,
listZipEntries,
} from '@/lib/zip-entry-reader'
jest.mock('@/lib/upload-paths', () => ({
getUploadRootDir: jest.fn(() => tempRoot),
resolveStoredUploadPath: jest.fn((stored: string) => stored),
}))
let tempRoot: string
beforeEach(async () => {
tempRoot = await mkdtemp(path.join(tmpdir(), 'zip-reader-test-'))
})
afterEach(async () => {
await rm(tempRoot, { recursive: true, force: true })
})
async function writeFixtureZip(name: string, entries: Array<{ name: string; data: Buffer | string; store?: boolean }>) {
const zipPath = path.join(tempRoot, name)
if (entries.some((entry) => entry.store)) {
// store 条目走 archiver,覆盖 method 0 分支
const archiver = (await import('archiver')).default
const { createWriteStream } = await import('fs')
const output = createWriteStream(zipPath)
const archive = archiver('zip', { zlib: { level: 0 } })
const done = new Promise<void>((resolve, reject) => {
output.on('close', () => resolve())
output.on('error', reject)
archive.on('error', reject)
})
archive.pipe(output)
for (const entry of entries) {
const filePath = path.join(tempRoot, `src-${entry.name}`)
await (await import('fs/promises')).writeFile(filePath, entry.data)
archive.file(filePath, { name: entry.name, ...(entry.store ? { store: true } : {}) })
}
await archive.finalize()
await done
return zipPath
}
const zip = new AdmZip()
for (const entry of entries) {
zip.addFile(entry.name, entry.data as Buffer)
}
zip.writeZip(zipPath)
return zipPath
}
describe('zip-entry-reader', () => {
test('按中央目录顺序列出条目并解压内容一致(deflate)', async () => {
const zipPath = await writeFixtureZip('deflate.zip', [
{ name: 'b-second.jpg', data: Buffer.from('second-image-bytes') },
{ name: 'a-first.jpg', data: Buffer.alloc(64 * 1024, 7) },
{ name: '中文图.png', data: Buffer.from([1, 2, 3, 4, 5]) },
])
const entries = await listZipEntries(zipPath)
// adm-zip 写包时的目录顺序与插入顺序不保证一致,这里只校验完整性;
// 顺序一致性由 list 与 extract 共用同一中央目录保证。
expect([...entries.map((entry) => entry.name)].sort()).toEqual(
['a-first.jpg', 'b-second.jpg', '中文图.png'].sort(),
)
const destination = path.join(tempRoot, 'out-deflate')
const extracted = await extractZipEntriesToDirectory(zipPath, destination)
expect(extracted.length).toBe(3)
// 列表与解压保持同一顺序
expect(extracted.map((item) => item.entryName)).toEqual(entries.map((entry) => entry.name))
const byName = new Map(extracted.map((item) => [item.entryName, item]))
const second = await readFile(byName.get('b-second.jpg')!.filePath)
expect(second.toString()).toBe('second-image-bytes')
const big = await readFile(byName.get('a-first.jpg')!.filePath)
expect(big.length).toBe(64 * 1024)
expect(big[0]).toBe(7)
const chinese = await readFile(byName.get('中文图.png')!.filePath)
expect(Array.from(chinese)).toEqual([1, 2, 3, 4, 5])
})
test('store 条目按原始字节拷贝', async () => {
const payload = Buffer.alloc(32 * 1024, 9)
const zipPath = await writeFixtureZip('store.zip', [
{ name: 'raw.bin', data: payload, store: true },
])
const destination = path.join(tempRoot, 'out-store')
const [extracted] = await extractZipEntriesToDirectory(zipPath, destination)
expect(extracted.entryName).toBe('raw.bin')
expect((await readFile(extracted.filePath)).equals(payload)).toBe(true)
})
test('空压缩包返回空列表', async () => {
const zipPath = await writeFixtureZip('empty.zip', [])
expect(await listZipEntries(zipPath)).toEqual([])
})
test('非 ZIP 文件报错', async () => {
const notZip = path.join(tempRoot, 'not.zip')
await (await import('fs/promises')).writeFile(notZip, Buffer.from('plain text, not a zip'))
await expect(listZipEntries(notZip)).rejects.toThrow('ZIP')
})
test('编辑工作目录创建后可清理', async () => {
const workspace = await createTaskDetailEditWorkspace()
await workspace.cleanup()
await expect(readdir(workspace.dir)).rejects.toBeDefined()
})
})

File diff suppressed because it is too large Load Diff

383
lib/zip-entry-reader.ts Normal file
View File

@ -0,0 +1,383 @@
import { createReadStream } from 'fs'
import { mkdir, open, rm } from 'fs/promises'
import { randomUUID } from 'crypto'
import path from 'path'
import { pipeline } from 'stream/promises'
import { createInflateRaw } from 'zlib'
/**
* 任务-详情编辑用的流式 ZIP 读取器。
*
* 只依赖 Node 自带 zlib:读中央目录定位每个条目,再按条目把解压结果
* 流式写到磁盘。全程不把整个压缩包读进内存,历史素材的分组 ZIP
* (主图/SKU图/详情图)可能数百 MB 甚至更大。
*
* 只支持本系统自己打包出来的常规布局:
* - method 0(store)/ method 8(deflate)
* - UTF-8 文件名、数据描述符(flag bit 3)、ZIP64 扩展
* 不支持加密与其它压缩方法,遇到即报错。
*/
const EOCD_SIGNATURE = 0x06054b50
const ZIP64_EOCD_LOCATOR_SIGNATURE = 0x07064b50
const ZIP64_EOCD_SIGNATURE = 0x06064b50
const CENTRAL_HEADER_SIGNATURE = 0x02014b50
const LOCAL_HEADER_SIGNATURE = 0x04034b50
export interface ZipEntryInfo {
/** 中央目录中的原始文件名 */
name: string
compressedSize: number
uncompressedSize: number
compressionMethod: number
localHeaderOffset: number
flags: number
}
interface ZipCentralDirectory {
entries: ZipEntryInfo[]
}
function readUInt16LE(buffer: Buffer, offset: number): number {
return buffer.readUInt16LE(offset)
}
function readUInt32LE(buffer: Buffer, offset: number): number {
return buffer.readUInt32LE(offset)
}
function readBigUInt64LE(buffer: Buffer, offset: number): number {
const value = buffer.readBigUInt64LE(offset)
if (value > BigInt(Number.MAX_SAFE_INTEGER)) {
throw new Error('ZIP 内的偏移或大小超出安全范围')
}
return Number(value)
}
async function readSlice(
handle: Awaited<ReturnType<typeof open>>,
position: number,
length: number,
): Promise<Buffer> {
const buffer = Buffer.alloc(length)
const { bytesRead } = await handle.read(buffer, 0, length, position)
if (bytesRead !== length) {
throw new Error('压缩包已损坏:读取内容不完整')
}
return buffer
}
function findEocdSignature(tail: Buffer): number {
// EOCD 固定 22 字节起,注释最长 65535,从尾部向前扫签名。
for (let offset = tail.length - 22; offset >= 0; offset -= 1) {
if (readUInt32LE(tail, offset) === EOCD_SIGNATURE) {
return offset
}
}
throw new Error('文件不是有效的 ZIP 压缩包')
}
async function locateZip64Eocd(
handle: Awaited<ReturnType<typeof open>>,
eocdOffsetInTail: number,
tailStart: number,
fileSize: number,
): Promise<{ entryCount: number; cdSize: number; cdOffset: number } | null> {
if (eocdOffsetInTail < 20) {
return null
}
const locatorPosition = tailStart + eocdOffsetInTail - 20
if (locatorPosition < 0 || locatorPosition + 20 > fileSize) {
return null
}
const locator = await readSlice(handle, locatorPosition, 20)
if (readUInt32LE(locator, 0) !== ZIP64_EOCD_LOCATOR_SIGNATURE) {
return null
}
const zip64EocdOffset = readBigUInt64LE(locator, 8)
const zip64Eocd = await readSlice(handle, zip64EocdOffset, 56)
if (readUInt32LE(zip64Eocd, 0) !== ZIP64_EOCD_SIGNATURE) {
throw new Error('压缩包已损坏:ZIP64 目录定位失败')
}
const entryCount = readBigUInt64LE(zip64Eocd, 32)
const cdSize = readBigUInt64LE(zip64Eocd, 40)
const cdOffset = readBigUInt64LE(zip64Eocd, 48)
return { entryCount, cdSize, cdOffset }
}
async function readCentralDirectory(
zipPath: string,
): Promise<ZipCentralDirectory> {
const handle = await open(zipPath, 'r')
try {
const stats = await handle.stat()
if (!stats.isFile() || stats.size < 22) {
throw new Error('文件不是有效的 ZIP 压缩包')
}
const tailLength = Math.min(stats.size, 22 + 65535 + 20 + 56)
const tailStart = stats.size - tailLength
const tail = await readSlice(handle, tailStart, tailLength)
const eocdOffsetInTail = findEocdSignature(tail)
const eocdAbsolute = tailStart + eocdOffsetInTail
let entryCount = readUInt16LE(tail, eocdOffsetInTail + 10)
let cdSize = readUInt32LE(tail, eocdOffsetInTail + 12)
let cdOffset = readUInt32LE(tail, eocdOffsetInTail + 16)
if (
entryCount === 0xffff
|| cdSize === 0xffffffff
|| cdOffset === 0xffffffff
) {
const zip64 = await locateZip64Eocd(
handle,
eocdOffsetInTail,
tailStart,
stats.size,
)
if (!zip64) {
throw new Error('压缩包已损坏:ZIP64 目录缺失')
}
entryCount = zip64.entryCount
cdSize = zip64.cdSize
cdOffset = zip64.cdOffset
}
if (entryCount <= 0) {
return { entries: [] }
}
const directoryBuffer = await readSlice(handle, cdOffset, cdSize)
const entries: ZipEntryInfo[] = []
let cursor = 0
for (let index = 0; index < entryCount; index += 1) {
if (cursor + 46 > directoryBuffer.length) {
throw new Error('压缩包已损坏:中央目录不完整')
}
if (readUInt32LE(directoryBuffer, cursor) !== CENTRAL_HEADER_SIGNATURE) {
throw new Error('压缩包已损坏:中央目录条目签名异常')
}
const flags = readUInt16LE(directoryBuffer, cursor + 8)
const compressionMethod = readUInt16LE(directoryBuffer, cursor + 10)
let compressedSize = readUInt32LE(directoryBuffer, cursor + 20)
let uncompressedSize = readUInt32LE(directoryBuffer, cursor + 24)
const nameLength = readUInt16LE(directoryBuffer, cursor + 28)
const extraLength = readUInt16LE(directoryBuffer, cursor + 30)
const commentLength = readUInt16LE(directoryBuffer, cursor + 32)
let localHeaderOffset = readUInt32LE(directoryBuffer, cursor + 42)
const extraStart = cursor + 46 + nameLength
const extraEnd = extraStart + extraLength
if (extraEnd > directoryBuffer.length) {
throw new Error('压缩包已损坏:中央目录扩展字段不完整')
}
let extraCursor = extraStart
while (extraCursor + 4 <= extraEnd) {
const headerId = readUInt16LE(directoryBuffer, extraCursor)
const dataSize = readUInt16LE(directoryBuffer, extraCursor + 2)
if (headerId === 0x0001) {
let fieldCursor = extraCursor + 4
const fieldEnd = extraCursor + 4 + dataSize
if (uncompressedSize === 0xffffffff && fieldCursor + 8 <= fieldEnd) {
uncompressedSize = readBigUInt64LE(directoryBuffer, fieldCursor)
fieldCursor += 8
}
if (compressedSize === 0xffffffff && fieldCursor + 8 <= fieldEnd) {
compressedSize = readBigUInt64LE(directoryBuffer, fieldCursor)
fieldCursor += 8
}
if (localHeaderOffset === 0xffffffff && fieldCursor + 8 <= fieldEnd) {
localHeaderOffset = readBigUInt64LE(directoryBuffer, fieldCursor)
fieldCursor += 8
}
}
extraCursor += 4 + dataSize
}
const name = directoryBuffer.toString('utf8', cursor + 46, extraStart)
entries.push({
name,
compressedSize,
uncompressedSize,
compressionMethod,
localHeaderOffset,
flags,
})
cursor = extraEnd + commentLength
}
return { entries }
} finally {
await handle.close()
}
}
/** 列出 ZIP 内的条目(按中央目录顺序,跳过目录项)。 */
export async function listZipEntries(zipPath: string): Promise<ZipEntryInfo[]> {
const { entries } = await readCentralDirectory(zipPath)
return entries.filter((entry) => !entry.name.endsWith('/'))
}
/**
* 把 ZIP 条目解压到目标目录。
*
* 返回值保持中央目录顺序;文件名只保留最后一段路径,
* 拒绝空名和穿越命名,避免历史数据里的异常条目写出目录树。
*/
export async function extractZipEntriesToDirectory(
zipPath: string,
destinationDir: string,
): Promise<Array<{ entryName: string; filePath: string; size: number }>> {
const { entries } = await readCentralDirectory(zipPath)
await mkdir(destinationDir, { recursive: true })
const extracted: Array<{ entryName: string; filePath: string; size: number }> = []
for (const entry of entries) {
if (entry.name.endsWith('/')) {
continue
}
const normalized = entry.name.replace(/\\/g, '/')
const baseName = normalized.split('/').pop() || ''
if (!baseName || baseName === '.' || baseName === '..') {
throw new Error(`压缩包含异常文件名,拒绝处理: ${entry.name}`)
}
if ((entry.flags & 0x1) !== 0) {
throw new Error(`压缩包含加密文件,不支持编辑: ${baseName}`)
}
const outputPath = path.join(destinationDir, `${extracted.length}-${baseName}`)
const written = await extractSingleEntry(handle => extractByEntry(handle, entry, outputPath))
if (written !== entry.uncompressedSize) {
throw new Error(`压缩包已损坏:条目 ${baseName} 解压后大小不一致`)
}
extracted.push({
entryName: baseName,
filePath: outputPath,
size: entry.uncompressedSize,
})
}
return extracted
async function extractSingleEntry(
worker: (handle: Awaited<ReturnType<typeof open>>) => Promise<number>,
): Promise<number> {
const handle = await open(zipPath, 'r')
try {
return await worker(handle)
} finally {
await handle.close()
}
}
}
async function extractByEntry(
handle: Awaited<ReturnType<typeof open>>,
entry: ZipEntryInfo,
outputPath: string,
): Promise<number> {
const localHeader = await readSlice(handle, entry.localHeaderOffset, 30)
if (readUInt32LE(localHeader, 0) !== LOCAL_HEADER_SIGNATURE) {
throw new Error(`压缩包已损坏:条目 ${entry.name} 本地头异常`)
}
const nameLength = readUInt16LE(localHeader, 26)
const extraLength = readUInt16LE(localHeader, 28)
const dataStart = entry.localHeaderOffset + 30 + nameLength + extraLength
if (entry.compressionMethod === 0) {
// store:直接按范围拷贝原始字节
return copyRangeToFile(handle, dataStart, entry.compressedSize, outputPath)
}
if (entry.compressionMethod === 8) {
const source = createRangeReadStream(handle, dataStart, entry.compressedSize)
const inflater = createInflateRaw()
let inflatedBytes = 0
inflater.on('data', (chunk: Buffer) => {
inflatedBytes += chunk.length
})
const output = await createFileWriteStream(outputPath)
try {
await pipeline(source, inflater, output)
} catch (error) {
output.destroy()
throw error
}
return inflatedBytes
}
throw new Error(`压缩包含不支持的压缩方式(method ${entry.compressionMethod}),无法编辑`)
}
// ---- 以下为内部辅助,避免引入额外依赖 ----
async function copyRangeToFile(
handle: Awaited<ReturnType<typeof open>>,
start: number,
length: number,
outputPath: string,
): Promise<number> {
const CHUNK = 1024 * 1024
const output = await createFileWriteStream(outputPath)
let written = 0
try {
let position = start
while (written < length) {
const size = Math.min(CHUNK, length - written)
const buffer = await readSlice(handle, position, size)
await new Promise<void>((resolve, reject) => {
output.write(buffer, (error) => (error ? reject(error) : resolve()))
})
written += size
position += size
}
await new Promise<void>((resolve, reject) => {
output.end(() => resolve())
output.on('error', reject)
})
return written
} catch (error) {
output.destroy()
throw error
}
}
function createRangeReadStream(
handle: Awaited<ReturnType<typeof open>>,
start: number,
length: number,
) {
// fd 由外层 close;createReadStream 仅负责按窗口读取
return createReadStream('', {
fd: handle.fd,
autoClose: false,
start,
end: start + length - 1,
})
}
async function createFileWriteStream(outputPath: string) {
const { createWriteStream } = await import('fs')
return createWriteStream(outputPath, { flags: 'wx' })
}
/** 创建任务-详情编辑用的临时工作目录,返回路径与清理函数。 */
export async function createTaskDetailEditWorkspace(): Promise<{
dir: string
cleanup: () => Promise<void>
}> {
const { getUploadRootDir } = await import('@/lib/upload-paths')
const dir = path.join(getUploadRootDir(), 'temp', `task-detail-edit-${randomUUID()}`)
await mkdir(dir, { recursive: true })
return {
dir,
cleanup: () => rm(dir, { recursive: true, force: true }),
}
}