任务详情支持单图替换改名删除并同步重建下载包
This commit is contained in:
parent
2957adfacb
commit
2b615ffc75
140
app/api/materials/[id]/assets/[assetId]/route.ts
Normal file
140
app/api/materials/[id]/assets/[assetId]/route.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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 },
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
118
lib/__tests__/task-detail-asset-edit-route.test.ts
Normal file
118
lib/__tests__/task-detail-asset-edit-route.test.ts
Normal 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('您没有权限编辑此素材')
|
||||
})
|
||||
})
|
||||
114
lib/__tests__/task-detail-preview-runs.test.ts
Normal file
114
lib/__tests__/task-detail-preview-runs.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
101
lib/__tests__/task-detail-zip-entry-order.test.ts
Normal file
101
lib/__tests__/task-detail-zip-entry-order.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
122
lib/__tests__/zip-entry-reader.test.ts
Normal file
122
lib/__tests__/zip-entry-reader.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
1222
lib/task-detail-asset-edit.ts
Normal file
1222
lib/task-detail-asset-edit.ts
Normal file
File diff suppressed because it is too large
Load Diff
383
lib/zip-entry-reader.ts
Normal file
383
lib/zip-entry-reader.ts
Normal 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 }),
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user