feat: add task detail image approval and insertion flow
This commit is contained in:
parent
378ae08ec4
commit
71b8f30d5b
@ -87,7 +87,8 @@ RUN test -s "/app/public/brand/header-logo.png" && \
|
||||
test -f "/app/prisma/migrations/20260826_add_ding_workflow_demand_type/migration.sql" && \
|
||||
test -f "/app/prisma/migrations/20260827_add_ding_workflow_detail_fields/migration.sql" && \
|
||||
test -f "/app/prisma/migrations/20260826_add_pack_video_early_access/migration.sql" && \
|
||||
test -f "/app/prisma/migrations/20260828_add_pack_item_can_edit_assets/migration.sql"
|
||||
test -f "/app/prisma/migrations/20260828_add_pack_item_can_edit_assets/migration.sql" && \
|
||||
test -f "/app/prisma/migrations/20260828_add_task_detail_change_approvals/migration.sql"
|
||||
|
||||
# 这两个动态路由负责兜底访问本地上传文件;如果它们在构建上下文里丢失,
|
||||
# 线上音频/视频就会直接 404。这里提前失败,避免再把缺路由的镜像发出去。
|
||||
@ -208,6 +209,7 @@ COPY --from=builder --chown=root:root /app/prisma/migrations/20260826_add_ding_w
|
||||
COPY --from=builder --chown=root:root /app/prisma/migrations/20260827_add_ding_workflow_detail_fields ./prisma/migrations/20260827_add_ding_workflow_detail_fields
|
||||
COPY --from=builder --chown=root:root /app/prisma/migrations/20260826_add_pack_video_early_access ./prisma/migrations/20260826_add_pack_video_early_access
|
||||
COPY --from=builder --chown=root:root /app/prisma/migrations/20260828_add_pack_item_can_edit_assets ./prisma/migrations/20260828_add_pack_item_can_edit_assets
|
||||
COPY --from=builder --chown=root:root /app/prisma/migrations/20260828_add_task_detail_change_approvals ./prisma/migrations/20260828_add_task_detail_change_approvals
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/scripts/public-traffic-rollup-from-nginx.py ./scripts/public-traffic-rollup-from-nginx.py
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/scripts/collapse-performance-survey-template-versions.mjs ./scripts/collapse-performance-survey-template-versions.mjs
|
||||
|
||||
@ -14,10 +14,11 @@ import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import {
|
||||
TaskDetailAssetEditError,
|
||||
deleteTaskDetailImage,
|
||||
renameTaskDetailImage,
|
||||
replaceTaskDetailImage,
|
||||
} from '@/lib/task-detail-asset-edit'
|
||||
import {
|
||||
submitTaskDetailChange,
|
||||
TaskDetailChangeApprovalError,
|
||||
} from '@/lib/task-detail-change-approval'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
@ -25,16 +26,16 @@ 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
|
||||
function getSessionUser(session: unknown): { id: string; role?: string | null; isSuperAdmin?: boolean } | null {
|
||||
const user = (session as { user?: { id?: string; role?: string; isSuperAdmin?: boolean } } | null)?.user
|
||||
if (!user?.id) {
|
||||
return null
|
||||
}
|
||||
return { id: user.id, role: user.role ?? null }
|
||||
return { id: user.id, role: user.role ?? null, isSuperAdmin: Boolean(user.isSuperAdmin) }
|
||||
}
|
||||
|
||||
function handleEditError(error: unknown) {
|
||||
if (error instanceof TaskDetailAssetEditError) {
|
||||
if (error instanceof TaskDetailAssetEditError || error instanceof TaskDetailChangeApprovalError) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error.message },
|
||||
{ status: error.statusCode },
|
||||
@ -67,15 +68,13 @@ export async function PATCH(
|
||||
)
|
||||
}
|
||||
|
||||
const result = await renameTaskDetailImage({
|
||||
materialId: params.id,
|
||||
const result = await submitTaskDetailChange(params.id, operator, [{
|
||||
kind: 'rename',
|
||||
assetId: params.assetId,
|
||||
operatorUserId: operator.id,
|
||||
operatorRole: operator.role,
|
||||
newFileName,
|
||||
})
|
||||
}])
|
||||
|
||||
return NextResponse.json({ success: true, data: result })
|
||||
return NextResponse.json({ success: true, ...result })
|
||||
} catch (error) {
|
||||
return handleEditError(error)
|
||||
}
|
||||
@ -101,15 +100,13 @@ export async function POST(
|
||||
)
|
||||
}
|
||||
|
||||
const result = await replaceTaskDetailImage({
|
||||
materialId: params.id,
|
||||
const result = await submitTaskDetailChange(params.id, operator, [{
|
||||
kind: 'replace',
|
||||
assetId: params.assetId,
|
||||
operatorUserId: operator.id,
|
||||
operatorRole: operator.role,
|
||||
uploadId,
|
||||
})
|
||||
}])
|
||||
|
||||
return NextResponse.json({ success: true, data: result })
|
||||
return NextResponse.json({ success: true, ...result })
|
||||
} catch (error) {
|
||||
return handleEditError(error)
|
||||
}
|
||||
@ -126,14 +123,12 @@ export async function DELETE(
|
||||
return NextResponse.json({ success: false, error: '未登录' }, { status: 401 })
|
||||
}
|
||||
|
||||
const result = await deleteTaskDetailImage({
|
||||
materialId: params.id,
|
||||
const result = await submitTaskDetailChange(params.id, operator, [{
|
||||
kind: 'delete',
|
||||
assetId: params.assetId,
|
||||
operatorUserId: operator.id,
|
||||
operatorRole: operator.role,
|
||||
})
|
||||
}])
|
||||
|
||||
return NextResponse.json({ success: true, data: result })
|
||||
return NextResponse.json({ success: true, ...result })
|
||||
} catch (error) {
|
||||
return handleEditError(error)
|
||||
}
|
||||
|
||||
@ -17,9 +17,12 @@ import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import {
|
||||
TaskDetailAssetEditError,
|
||||
batchEditTaskDetailImages,
|
||||
type BatchTaskDetailImageEditOperation,
|
||||
} from '@/lib/task-detail-asset-edit'
|
||||
import {
|
||||
submitTaskDetailChange,
|
||||
TaskDetailChangeApprovalError,
|
||||
} from '@/lib/task-detail-change-approval'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
@ -27,12 +30,12 @@ export const maxDuration = 10800
|
||||
|
||||
type RouteParams = { params: { id: string } }
|
||||
|
||||
function getSessionUser(session: unknown): { id: string; role?: string | null } | null {
|
||||
const user = (session as { user?: { id?: string; role?: string } } | null)?.user
|
||||
function getSessionUser(session: unknown): { id: string; role?: string | null; isSuperAdmin?: boolean } | null {
|
||||
const user = (session as { user?: { id?: string; role?: string; isSuperAdmin?: boolean } } | null)?.user
|
||||
if (!user?.id) {
|
||||
return null
|
||||
}
|
||||
return { id: user.id, role: user.role ?? null }
|
||||
return { id: user.id, role: user.role ?? null, isSuperAdmin: Boolean(user.isSuperAdmin) }
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
@ -78,16 +81,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
return NextResponse.json({ success: false, error: '批量操作不能为空' }, { status: 400 })
|
||||
}
|
||||
|
||||
const result = await batchEditTaskDetailImages({
|
||||
materialId: params.id,
|
||||
operatorUserId: operator.id,
|
||||
operatorRole: operator.role,
|
||||
operations,
|
||||
})
|
||||
const result = await submitTaskDetailChange(params.id, operator, operations)
|
||||
|
||||
return NextResponse.json({ success: true, data: result })
|
||||
return NextResponse.json({ success: true, ...result })
|
||||
} catch (error) {
|
||||
if (error instanceof TaskDetailAssetEditError) {
|
||||
if (error instanceof TaskDetailAssetEditError || error instanceof TaskDetailChangeApprovalError) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error.message },
|
||||
{ status: error.statusCode },
|
||||
|
||||
59
app/api/materials/[id]/assets/change-requests/route.ts
Normal file
59
app/api/materials/[id]/assets/change-requests/route.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import {
|
||||
listTaskDetailChanges,
|
||||
submitTaskDetailChange,
|
||||
TaskDetailChangeApprovalError,
|
||||
} from '@/lib/task-detail-change-approval'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const maxDuration = 10800
|
||||
|
||||
type RouteParams = { params: { id: string } }
|
||||
|
||||
function actor(session: unknown) {
|
||||
const user = (session as { user?: { id?: string; role?: string; isSuperAdmin?: boolean } } | null)?.user
|
||||
return user?.id
|
||||
? { id: user.id, role: user.role || null, isSuperAdmin: Boolean(user.isSuperAdmin) }
|
||||
: null
|
||||
}
|
||||
|
||||
function errorResponse(error: unknown) {
|
||||
if (error instanceof TaskDetailChangeApprovalError) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: error.statusCode })
|
||||
}
|
||||
console.error('[task-detail-change] request error:', error)
|
||||
return NextResponse.json({ success: false, error: '图片变更处理失败,请稍后重试' }, { status: 500 })
|
||||
}
|
||||
|
||||
export async function GET(_request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
const current = actor(session)
|
||||
if (!current) return NextResponse.json({ success: false, error: '未登录' }, { status: 401 })
|
||||
const rows = await listTaskDetailChanges(params.id, current)
|
||||
return NextResponse.json({ success: true, data: rows })
|
||||
} catch (error) {
|
||||
return errorResponse(error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
const current = actor(session)
|
||||
if (!current) return NextResponse.json({ success: false, error: '未登录' }, { status: 401 })
|
||||
const body = await request.json().catch(() => null)
|
||||
const result = await submitTaskDetailChange(
|
||||
params.id,
|
||||
current,
|
||||
(body as { operations?: unknown } | null)?.operations,
|
||||
)
|
||||
return NextResponse.json({ success: true, ...result })
|
||||
} catch (error) {
|
||||
return errorResponse(error)
|
||||
}
|
||||
}
|
||||
39
app/api/task-detail-change-requests/[id]/decision/route.ts
Normal file
39
app/api/task-detail-change-requests/[id]/decision/route.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import {
|
||||
decideTaskDetailChange,
|
||||
TaskDetailChangeApprovalError,
|
||||
} from '@/lib/task-detail-change-approval'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const maxDuration = 10800
|
||||
|
||||
type RouteParams = { params: { id: string } }
|
||||
|
||||
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
const user = session?.user as { id?: string; role?: string; isSuperAdmin?: boolean } | undefined
|
||||
if (!user?.id) return NextResponse.json({ success: false, error: '未登录' }, { status: 401 })
|
||||
const body = await request.json().catch(() => null) as { decision?: unknown } | null
|
||||
const decision = String(body?.decision || '')
|
||||
if (decision !== 'approve' && decision !== 'reject') {
|
||||
return NextResponse.json({ success: false, error: '请选择批准或否决' }, { status: 400 })
|
||||
}
|
||||
const data = await decideTaskDetailChange(params.id, {
|
||||
id: user.id,
|
||||
role: user.role || null,
|
||||
isSuperAdmin: Boolean(user.isSuperAdmin),
|
||||
}, decision)
|
||||
return NextResponse.json({ success: true, data })
|
||||
} catch (error) {
|
||||
if (error instanceof TaskDetailChangeApprovalError) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: error.statusCode })
|
||||
}
|
||||
console.error('[task-detail-change] decision error:', error)
|
||||
return NextResponse.json({ success: false, error: '确认图片变更失败,请稍后重试' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
33
app/api/task-detail-change-requests/[id]/route.ts
Normal file
33
app/api/task-detail-change-requests/[id]/route.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import {
|
||||
getTaskDetailChangeRequest,
|
||||
TaskDetailChangeApprovalError,
|
||||
} from '@/lib/task-detail-change-approval'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
type RouteParams = { params: { id: string } }
|
||||
|
||||
export async function GET(_request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
const user = session?.user as { id?: string; role?: string; isSuperAdmin?: boolean } | undefined
|
||||
if (!user?.id) return NextResponse.json({ success: false, error: '未登录' }, { status: 401 })
|
||||
const data = await getTaskDetailChangeRequest(params.id, {
|
||||
id: user.id,
|
||||
role: user.role || null,
|
||||
isSuperAdmin: Boolean(user.isSuperAdmin),
|
||||
})
|
||||
return NextResponse.json({ success: true, data })
|
||||
} catch (error) {
|
||||
if (error instanceof TaskDetailChangeApprovalError) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: error.statusCode })
|
||||
}
|
||||
console.error('[task-detail-change] detail error:', error)
|
||||
return NextResponse.json({ success: false, error: '读取图片变更申请失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
||||
import SharePage from './page'
|
||||
@ -6,20 +6,73 @@ import SharePage from './page'
|
||||
const mockPush = jest.fn()
|
||||
const mockReplace = jest.fn()
|
||||
const mockBack = jest.fn()
|
||||
const showToastMock = jest.fn()
|
||||
const chunkUploadMock = jest.fn()
|
||||
const originalFetch = global.fetch
|
||||
|
||||
const createVideoWaitResponse = () => ({
|
||||
ok: false,
|
||||
status: 403,
|
||||
json: async () => ({
|
||||
error: '视频类素材上传后满 15 天才能查看详情',
|
||||
code: 'VIDEO_WAIT_PERIOD',
|
||||
details: '视频类素材上传后满 15 天才能预览和下载,当前还需约 12 天',
|
||||
availableAt: '2026-09-01T00:00:00.000Z',
|
||||
remainingDays: 12,
|
||||
}),
|
||||
const createJsonResponse = <T,>(body: T, status = 200) => ({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
})
|
||||
|
||||
const createVideoWaitResponse = () => createJsonResponse({
|
||||
error: '视频类素材上传后满 15 天才能查看详情',
|
||||
code: 'VIDEO_WAIT_PERIOD',
|
||||
details: '视频类素材上传后满 15 天才能预览和下载,当前还需约 12 天',
|
||||
availableAt: '2026-09-01T00:00:00.000Z',
|
||||
remainingDays: 12,
|
||||
}, 403)
|
||||
|
||||
const createTaskDetailMaterialResponse = () => createJsonResponse({
|
||||
id: 'material-1',
|
||||
userId: 'user-1',
|
||||
previewUrl: '/preview.jpg',
|
||||
productModel: 'GBSFY-SJZ0024',
|
||||
categoryName: '顾家',
|
||||
sceneName: '客厅',
|
||||
materialType: '任务-详情',
|
||||
authorName: '小李',
|
||||
zipFileName: 'bundle.zip',
|
||||
zipFileSize: '1024',
|
||||
downloadCount: 0,
|
||||
likeCount: 0,
|
||||
displayImages: [],
|
||||
createdAt: '2026-08-28T09:00:00.000Z',
|
||||
capabilities: {
|
||||
canFavorite: true,
|
||||
canEdit: true,
|
||||
canDelete: false,
|
||||
},
|
||||
taskNo: 'TASK-001',
|
||||
taskStatus: 'ready',
|
||||
taskFetchedAt: null,
|
||||
isFullSet: true,
|
||||
canDownloadSource: false,
|
||||
canManageTaskDetailAssets: true,
|
||||
assets: [
|
||||
{ id: 'main-1', assetType: 'main-preview', fileUrl: '/main-1.jpg', fileName: 'main-1.jpg', fileSize: 100, sortOrder: 0 },
|
||||
{ id: 'main-2', assetType: 'main-preview', fileUrl: '/main-2.jpg', fileName: 'main-2.jpg', fileSize: 100, sortOrder: 1 },
|
||||
{ id: 'main-3', assetType: 'main-preview', fileUrl: '/main-3.jpg', fileName: 'main-3.jpg', fileSize: 100, sortOrder: 2 },
|
||||
{ id: 'sku-1', assetType: 'sku-preview', fileUrl: '/sku-1.jpg', fileName: 'sku-1.jpg', fileSize: 100, sortOrder: 0 },
|
||||
{ id: 'detail-1', assetType: 'detail-preview', fileUrl: '/detail-1.jpg', fileName: 'detail-1.jpg', fileSize: 100, sortOrder: 0 },
|
||||
{ id: 'detail-2', assetType: 'detail-preview', fileUrl: '/detail-2.jpg', fileName: 'detail-2.jpg', fileSize: 100, sortOrder: 1 },
|
||||
],
|
||||
})
|
||||
|
||||
jest.mock('@gsap/react', () => ({
|
||||
useGSAP: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('gsap', () => ({
|
||||
registerPlugin: jest.fn(),
|
||||
matchMedia: () => ({
|
||||
add: jest.fn(),
|
||||
revert: jest.fn(),
|
||||
}),
|
||||
fromTo: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('next-auth/react', () => ({
|
||||
useSession: () => ({ data: null, status: 'unauthenticated' }),
|
||||
}))
|
||||
@ -43,8 +96,75 @@ jest.mock('@/components/Header', () => ({
|
||||
default: () => <header data-testid="header" />,
|
||||
}))
|
||||
|
||||
jest.mock('@/components/VideoPlayerModal', () => ({
|
||||
__esModule: true,
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
jest.mock('@/components/SkuList', () => ({
|
||||
__esModule: true,
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
jest.mock('@/components/SkuEditModal', () => ({
|
||||
__esModule: true,
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
jest.mock('@/components/AudioVisualizer', () => ({
|
||||
__esModule: true,
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
jest.mock('@/components/DocumentAssetViewer', () => ({
|
||||
__esModule: true,
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
jest.mock('@/components/detail/MaterialDetailHeader', () => ({
|
||||
__esModule: true,
|
||||
default: ({ title }: { title: string }) => <div data-testid="material-detail-header">{title}</div>,
|
||||
}))
|
||||
|
||||
jest.mock('@/components/detail/TaskPageDetail', () => ({
|
||||
__esModule: true,
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
jest.mock('@/components/detail/ThreeDOriginalDetail', () => ({
|
||||
__esModule: true,
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
jest.mock('@/components/detail/ProductPackageDetail', () => ({
|
||||
__esModule: true,
|
||||
default: (props: any) => (
|
||||
<div data-testid="mock-product-package-detail">
|
||||
<button type="button" onClick={() => props.onRequestReplace?.({ id: 'main-1', fileName: 'main-1.jpg' })}>
|
||||
触发替换
|
||||
</button>
|
||||
<button type="button" onClick={() => props.onRequestRename?.({ id: 'main-2', fileName: 'main-2.jpg' })}>
|
||||
触发重命名
|
||||
</button>
|
||||
<button type="button" onClick={() => props.onRequestDelete?.({ id: 'main-3', fileName: 'main-3.jpg' })}>
|
||||
触发删除
|
||||
</button>
|
||||
<button type="button" onClick={() => props.onRequestAppend?.('main')}>
|
||||
触发追加主图
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onRequestInsert?.({ group: 'detail', afterAssetId: 'detail-1', beforeAssetId: 'detail-2' })}
|
||||
>
|
||||
触发插入详情
|
||||
</button>
|
||||
<div data-testid="pending-asset-ids">{(props.pendingAssetIds || []).join(',')}</div>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
jest.mock('@/hooks/useToast', () => ({
|
||||
useToast: () => ({ toast: null, showToast: jest.fn() }),
|
||||
useToast: () => ({ toast: null, showToast: showToastMock }),
|
||||
}))
|
||||
|
||||
jest.mock('@/components/ConfirmActionDialog', () => ({
|
||||
@ -54,6 +174,10 @@ jest.mock('@/components/ConfirmActionDialog', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/chunk-upload', () => ({
|
||||
chunkUpload: (...args: unknown[]) => chunkUploadMock(...args),
|
||||
}))
|
||||
|
||||
describe('video material share page', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
@ -110,3 +234,206 @@ describe('video material share page', () => {
|
||||
expect(await screen.findByRole('button', { name: '重新检查' })).toBeEnabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('task detail share page change requests', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
showToastMock.mockReset()
|
||||
chunkUploadMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
test('serializes all operation kinds while keeping each request inside one image group', async () => {
|
||||
const user = userEvent.setup()
|
||||
chunkUploadMock
|
||||
.mockResolvedValueOnce({ uploadId: 'upload-replace-1' })
|
||||
.mockResolvedValueOnce({ uploadId: 'upload-append-1' })
|
||||
.mockResolvedValueOnce({ uploadId: 'upload-append-2' })
|
||||
.mockResolvedValueOnce({ uploadId: 'upload-insert-1' })
|
||||
|
||||
const fetchMock = jest.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input)
|
||||
if (url === '/api/materials/share?id=video-material-1') {
|
||||
return createTaskDetailMaterialResponse()
|
||||
}
|
||||
if (url === '/api/materials/material-1/assets/change-requests' && init?.method === 'POST') {
|
||||
return createJsonResponse({ success: true, mode: 'applied' })
|
||||
}
|
||||
if (url === '/api/materials/material-1/assets/change-requests') {
|
||||
return createJsonResponse({ success: true, data: [] })
|
||||
}
|
||||
return createJsonResponse({ success: true })
|
||||
})
|
||||
global.fetch = fetchMock as jest.Mock
|
||||
|
||||
render(<SharePage />)
|
||||
expect(await screen.findByTestId('mock-product-package-detail')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '触发替换' }))
|
||||
await user.upload(
|
||||
screen.getByTestId('task-detail-replace-input'),
|
||||
new File(['replace'], 'replace-main.jpg', { type: 'image/jpeg' }),
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '触发重命名' }))
|
||||
const renameInput = await screen.findByRole('textbox', { name: '新文件名' })
|
||||
await user.clear(renameInput)
|
||||
await user.type(renameInput, 'detail-2-renamed')
|
||||
await user.click(screen.getByRole('button', { name: '加入待提交清单' }))
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '触发删除' }))
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '触发追加主图' }))
|
||||
await user.upload(
|
||||
screen.getByTestId('task-detail-batch-input'),
|
||||
[
|
||||
new File(['append-1'], 'append-main-1.jpg', { type: 'image/jpeg' }),
|
||||
new File(['append-2'], 'append-main-2.jpg', { type: 'image/jpeg' }),
|
||||
],
|
||||
)
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '提交主管确认 5 项' }))
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '触发插入详情' }))
|
||||
await user.upload(
|
||||
screen.getByTestId('task-detail-batch-input'),
|
||||
new File(['insert'], 'insert-detail.jpg', { type: 'image/jpeg' }),
|
||||
)
|
||||
await user.click(await screen.findByRole('button', { name: '提交主管确认 1 项' }))
|
||||
|
||||
const requestCalls = fetchMock.mock.calls.filter(
|
||||
([url, init]) => String(url) === '/api/materials/material-1/assets/change-requests'
|
||||
&& init?.method === 'POST',
|
||||
)
|
||||
expect(requestCalls).toHaveLength(2)
|
||||
expect(requestCalls[0]?.[1]).toMatchObject({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
const mainPayload = JSON.parse(String((requestCalls[0]?.[1] as RequestInit).body))
|
||||
expect(mainPayload).toEqual({
|
||||
operations: [
|
||||
{ kind: 'replace', assetId: 'main-1', uploadId: 'upload-replace-1' },
|
||||
{ kind: 'rename', assetId: 'main-2', newFileName: 'detail-2-renamed' },
|
||||
{ kind: 'delete', assetId: 'main-3' },
|
||||
{ kind: 'append', group: 'main', uploadId: 'upload-append-1' },
|
||||
{ kind: 'append', group: 'main', uploadId: 'upload-append-2' },
|
||||
],
|
||||
})
|
||||
const detailPayload = JSON.parse(String((requestCalls[1]?.[1] as RequestInit).body))
|
||||
expect(detailPayload).toEqual({
|
||||
operations: [
|
||||
{
|
||||
kind: 'insert',
|
||||
group: 'detail',
|
||||
uploadId: 'upload-insert-1',
|
||||
afterAssetId: 'detail-1',
|
||||
beforeAssetId: 'detail-2',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'已直接应用 1 项变更,下载包已同步更新',
|
||||
{ variant: 'success' },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test('shows pending approval details when server returns pending_approval mode', async () => {
|
||||
const user = userEvent.setup()
|
||||
const fetchMock = jest.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input)
|
||||
if (url === '/api/materials/share?id=video-material-1') {
|
||||
return createTaskDetailMaterialResponse()
|
||||
}
|
||||
if (url === '/api/materials/material-1/assets/change-requests' && init?.method === 'POST') {
|
||||
return createJsonResponse({
|
||||
success: true,
|
||||
mode: 'pending_approval',
|
||||
request: {
|
||||
id: 'request-1',
|
||||
status: 'pending_manager_approval',
|
||||
managerName: '王主管',
|
||||
expiresAt: '2026-09-01T12:00:00.000Z',
|
||||
},
|
||||
})
|
||||
}
|
||||
if (url === '/api/materials/material-1/assets/change-requests') {
|
||||
return createJsonResponse({ success: true, data: [] })
|
||||
}
|
||||
return createJsonResponse({ success: true })
|
||||
})
|
||||
global.fetch = fetchMock as jest.Mock
|
||||
|
||||
render(<SharePage />)
|
||||
expect(await screen.findByTestId('mock-product-package-detail')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '触发删除' }))
|
||||
await user.click(await screen.findByRole('button', { name: '提交主管确认 1 项' }))
|
||||
|
||||
expect(await screen.findByText('变更已提交主管确认')).toBeInTheDocument()
|
||||
expect(screen.getByText('经理:王主管')).toBeInTheDocument()
|
||||
expect(screen.getByText('到期:2026-09-01 20:00')).toBeInTheDocument()
|
||||
expect(screen.getByText('待主管确认')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '查看审批详情' }))
|
||||
expect(mockPush).toHaveBeenCalledWith('/task-detail-change-requests/request-1')
|
||||
})
|
||||
|
||||
test('blocks switching image groups before opening another upload picker', async () => {
|
||||
const user = userEvent.setup()
|
||||
global.fetch = jest.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input)
|
||||
if (url === '/api/materials/share?id=video-material-1') return createTaskDetailMaterialResponse()
|
||||
if (url === '/api/materials/material-1/assets/change-requests') {
|
||||
return createJsonResponse({ success: true, data: [] })
|
||||
}
|
||||
return createJsonResponse({ success: true })
|
||||
}) as jest.Mock
|
||||
|
||||
render(<SharePage />)
|
||||
expect(await screen.findByTestId('mock-product-package-detail')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '触发删除' }))
|
||||
await user.click(screen.getByRole('button', { name: '触发插入详情' }))
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'待提交清单已有主图变更,请先提交或清空后再编辑详情图',
|
||||
{ variant: 'error' },
|
||||
)
|
||||
})
|
||||
|
||||
test('restores the latest pending manager approval after refresh', async () => {
|
||||
const fetchMock = jest.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input)
|
||||
if (url === '/api/materials/share?id=video-material-1') {
|
||||
return createTaskDetailMaterialResponse()
|
||||
}
|
||||
if (url === '/api/materials/material-1/assets/change-requests') {
|
||||
return createJsonResponse({
|
||||
success: true,
|
||||
data: [{
|
||||
id: 'request-restored',
|
||||
status: 'pending_manager_approval',
|
||||
managerName: '赵主管',
|
||||
expiresAt: '2026-09-02T12:00:00.000Z',
|
||||
}],
|
||||
})
|
||||
}
|
||||
return createJsonResponse({ success: true })
|
||||
})
|
||||
global.fetch = fetchMock as jest.Mock
|
||||
|
||||
render(<SharePage />)
|
||||
|
||||
expect(await screen.findByText('变更已提交主管确认')).toBeInTheDocument()
|
||||
expect(screen.getByText('经理:赵主管')).toBeInTheDocument()
|
||||
expect(screen.getByText('待主管确认')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -90,6 +90,62 @@ interface Material {
|
||||
}>
|
||||
}
|
||||
|
||||
type TaskDetailAppendGroup = 'main' | 'sku'
|
||||
|
||||
type TaskDetailInsertIntent = {
|
||||
kind: 'insert'
|
||||
group: 'detail'
|
||||
afterAssetId: string
|
||||
beforeAssetId: string
|
||||
}
|
||||
|
||||
type TaskDetailBatchIntent =
|
||||
| { kind: 'append'; group: TaskDetailAppendGroup }
|
||||
| TaskDetailInsertIntent
|
||||
|
||||
type TaskDetailPendingOpInput =
|
||||
| {
|
||||
kind: 'append'
|
||||
group: TaskDetailAppendGroup
|
||||
label: string
|
||||
uploadId: string
|
||||
}
|
||||
| {
|
||||
kind: 'insert'
|
||||
group: 'detail'
|
||||
label: string
|
||||
uploadId: string
|
||||
afterAssetId: string
|
||||
beforeAssetId: string
|
||||
}
|
||||
| {
|
||||
kind: 'replace'
|
||||
assetId: string
|
||||
label: string
|
||||
uploadId: string
|
||||
}
|
||||
| {
|
||||
kind: 'rename'
|
||||
assetId: string
|
||||
label: string
|
||||
newFileName: string
|
||||
}
|
||||
| {
|
||||
kind: 'delete'
|
||||
assetId: string
|
||||
label: string
|
||||
}
|
||||
|
||||
type TaskDetailPendingOp = TaskDetailPendingOpInput & { key: string }
|
||||
|
||||
type TaskDetailChangeRequestNotice = {
|
||||
requestId: string | null
|
||||
mode: 'applied' | 'pending_approval'
|
||||
status: string | null
|
||||
managerName: string | null
|
||||
expiresAt: string | null
|
||||
}
|
||||
|
||||
interface VideoShareWaitState {
|
||||
message: string
|
||||
availableAt: string | null
|
||||
@ -98,6 +154,164 @@ interface VideoShareWaitState {
|
||||
|
||||
const VIDEO_SHARE_PROTECTION_DAYS = 15
|
||||
|
||||
const TASK_DETAIL_GROUP_LABELS: Record<'main' | 'sku' | 'detail', string> = {
|
||||
main: '主图',
|
||||
sku: 'SKU 图',
|
||||
detail: '详情图',
|
||||
}
|
||||
|
||||
const TASK_DETAIL_CHANGE_REQUEST_STATUS_META: Record<string, { label: string; className: string }> = {
|
||||
pending_approval: {
|
||||
label: '待主管确认',
|
||||
className: 'border-[#b8d0ff] bg-[#edf4ff] text-[#0052d9]',
|
||||
},
|
||||
pending_manager_approval: {
|
||||
label: '待主管确认',
|
||||
className: 'border-[#b8d0ff] bg-[#edf4ff] text-[#0052d9]',
|
||||
},
|
||||
applying: {
|
||||
label: '正在生效',
|
||||
className: 'border-[#b8d0ff] bg-[#edf4ff] text-[#0052d9]',
|
||||
},
|
||||
approved: {
|
||||
label: '已通过',
|
||||
className: 'border-[#b9ead0] bg-[#ecfdf3] text-[#087a3c]',
|
||||
},
|
||||
rejected: {
|
||||
label: '已否决',
|
||||
className: 'border-[#f3d29b] bg-[#fff8e8] text-[#8a5200]',
|
||||
},
|
||||
expired: {
|
||||
label: '已超时',
|
||||
className: 'border-[#e2e8f0] bg-[#f8fafc] text-[#66758b]',
|
||||
},
|
||||
apply_failed: {
|
||||
label: '应用失败',
|
||||
className: 'border-[#f5b9b9] bg-[#fff1f1] text-[#b42318]',
|
||||
},
|
||||
}
|
||||
|
||||
function readNestedText(record: Record<string, unknown>, key: string): string | null {
|
||||
const direct = record[key]
|
||||
if (typeof direct === 'string' && direct.trim()) return direct.trim()
|
||||
const nested = record[key] as Record<string, unknown> | null
|
||||
if (nested && typeof nested === 'object') {
|
||||
const name = nested.name
|
||||
if (typeof name === 'string' && name.trim()) return name.trim()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getTaskDetailChangeRequestStatusMeta(status: unknown) {
|
||||
const raw = String(status || '').trim().toLowerCase()
|
||||
if (!raw) return TASK_DETAIL_CHANGE_REQUEST_STATUS_META.pending_approval
|
||||
if (raw === 'approved' || raw === 'applied') return TASK_DETAIL_CHANGE_REQUEST_STATUS_META.approved
|
||||
if (raw === 'rejected' || raw === 'reject') return TASK_DETAIL_CHANGE_REQUEST_STATUS_META.rejected
|
||||
if (raw === 'expired' || raw === 'timeout' || raw === 'timed_out') return TASK_DETAIL_CHANGE_REQUEST_STATUS_META.expired
|
||||
return TASK_DETAIL_CHANGE_REQUEST_STATUS_META.pending_approval
|
||||
}
|
||||
|
||||
function formatTaskDetailDeadline(value: string | null) {
|
||||
if (!value) return '未提供'
|
||||
const parsed = new Date(value)
|
||||
if (Number.isNaN(parsed.getTime())) return value
|
||||
return format(parsed, 'yyyy-MM-dd HH:mm')
|
||||
}
|
||||
|
||||
function buildTaskDetailChangeRequestNotice(payload: unknown): TaskDetailChangeRequestNotice | null {
|
||||
if (!payload || typeof payload !== 'object') return null
|
||||
|
||||
const root = payload as Record<string, unknown>
|
||||
const candidate =
|
||||
(root.data as Record<string, unknown> | undefined)?.changeRequest
|
||||
|| root.changeRequest
|
||||
|| root.request
|
||||
|| root.data
|
||||
|| root
|
||||
|
||||
if (!candidate || typeof candidate !== 'object') return null
|
||||
const record = candidate as Record<string, unknown>
|
||||
const status = String(record.status || record.reviewStatus || '').trim().toLowerCase()
|
||||
const mode = (
|
||||
String(root.mode || record.mode || '').trim() === 'pending_approval'
|
||||
|| status === 'pending_manager_approval'
|
||||
|| status === 'applying'
|
||||
)
|
||||
? 'pending_approval'
|
||||
: 'applied'
|
||||
const requestId = typeof record.id === 'string'
|
||||
? record.id
|
||||
: typeof record.requestId === 'string'
|
||||
? record.requestId
|
||||
: null
|
||||
|
||||
return {
|
||||
requestId,
|
||||
mode,
|
||||
status:
|
||||
status
|
||||
? status
|
||||
: mode === 'pending_approval'
|
||||
? 'pending_approval'
|
||||
: null,
|
||||
managerName:
|
||||
readNestedText(record, 'manager')
|
||||
|| readNestedText(record, 'approver')
|
||||
|| readNestedText(record, 'reviewer')
|
||||
|| (typeof record.managerName === 'string' ? record.managerName : null)
|
||||
|| (typeof record.approverName === 'string' ? record.approverName : null)
|
||||
|| (typeof record.reviewerName === 'string' ? record.reviewerName : null),
|
||||
expiresAt:
|
||||
typeof record.expiresAt === 'string'
|
||||
? record.expiresAt
|
||||
: typeof record.deadlineAt === 'string'
|
||||
? record.deadlineAt
|
||||
: typeof record.expireAt === 'string'
|
||||
? record.expireAt
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
function TaskDetailChangeRequestBanner({
|
||||
notice,
|
||||
onOpenDetail,
|
||||
}: {
|
||||
notice: TaskDetailChangeRequestNotice
|
||||
onOpenDetail: () => void
|
||||
}) {
|
||||
const statusMeta = getTaskDetailChangeRequestStatusMeta(notice.status)
|
||||
|
||||
return (
|
||||
<div className="mb-4 rounded-xl border border-[#e7e8ea] bg-white p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-[13px] font-semibold text-[#1a1c1d]">
|
||||
{notice.mode === 'pending_approval' ? '变更已提交主管确认' : '变更已生效'}
|
||||
</p>
|
||||
<span className={`inline-flex rounded-full border px-2.5 py-1 text-[11px] font-bold ${statusMeta.className}`}>
|
||||
{statusMeta.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-[12px] text-[#6b7075]">
|
||||
<span>经理:{notice.managerName || '待分配'}</span>
|
||||
<span>到期:{formatTaskDetailDeadline(notice.expiresAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{notice.requestId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenDetail}
|
||||
className="inline-flex min-h-10 items-center justify-center rounded-[8px] border border-[#e7e8ea] bg-white px-4 text-[13px] font-medium text-[#1a1c1d] transition-colors hover:bg-[#fafafa] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2"
|
||||
>
|
||||
查看审批详情
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
gsap.registerPlugin(useGSAP)
|
||||
|
||||
function parseVideoShareWaitState(value: unknown): VideoShareWaitState | null {
|
||||
@ -315,9 +529,14 @@ export default function SharePage() {
|
||||
|
||||
// 任务-详情单图编辑状态
|
||||
const [taskDetailEditingAssetId, setTaskDetailEditingAssetId] = useState<string | null>(null)
|
||||
const [replaceUploadProgress, setReplaceUploadProgress] = useState<number | null>(null)
|
||||
const [taskDetailUploadProgress, setTaskDetailUploadProgress] = useState<{
|
||||
label: string
|
||||
progress: number
|
||||
} | null>(null)
|
||||
const replaceInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const taskDetailBatchInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const replaceTargetAssetIdRef = useRef<string | null>(null)
|
||||
const taskDetailBatchIntentRef = useRef<TaskDetailBatchIntent | null>(null)
|
||||
const [renameDialogState, setRenameDialogState] = useState<{
|
||||
assetId: string
|
||||
currentName: string
|
||||
@ -325,18 +544,12 @@ export default function SharePage() {
|
||||
extension: string
|
||||
value: string
|
||||
error: string | null
|
||||
saving: boolean
|
||||
} | null>(null)
|
||||
// 批量编辑攒批清单:用户的一组删除/改名/替换操作先攒在本地,点「应用」一次性提交
|
||||
const [taskDetailPendingOps, setTaskDetailPendingOps] = useState<Array<{
|
||||
key: string
|
||||
assetId: string
|
||||
kind: 'delete' | 'rename' | 'replace'
|
||||
label: string
|
||||
uploadId?: string
|
||||
newFileName?: string
|
||||
}>>([])
|
||||
saving: boolean
|
||||
} | null>(null)
|
||||
// 批量编辑攒批清单:先在前端整理一组变更,再统一提交给主管确认或直接应用。
|
||||
const [taskDetailPendingOps, setTaskDetailPendingOps] = useState<TaskDetailPendingOp[]>([])
|
||||
const [taskDetailApplying, setTaskDetailApplying] = useState(false)
|
||||
const [taskDetailChangeRequestNotice, setTaskDetailChangeRequestNotice] = useState<TaskDetailChangeRequestNotice | null>(null)
|
||||
const taskDetailPendingBarRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
// 应用变更的长等待动效:清单项逐项扫描推进 + 按钮呼吸,形成"正在逐项处理"的节奏感。
|
||||
@ -412,12 +625,34 @@ export default function SharePage() {
|
||||
useEffect(() => {
|
||||
// 分享页面允许未登录访问,立即开始加载,不等待session状态
|
||||
// 使用setTimeout确保在页面渲染后立即开始加载
|
||||
setTaskDetailPendingOps([])
|
||||
setTaskDetailChangeRequestNotice(null)
|
||||
const timer = setTimeout(() => {
|
||||
fetchMaterial()
|
||||
}, 0)
|
||||
return () => clearTimeout(timer)
|
||||
}, [materialId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!material?.id || !material.canManageTaskDetailAssets) return
|
||||
let cancelled = false
|
||||
void fetch(`/api/materials/${material.id}/assets/change-requests`, { cache: 'no-store' })
|
||||
.then(async (response) => {
|
||||
if (!response.ok) return null
|
||||
const payload = await response.json().catch(() => null)
|
||||
const rows = Array.isArray(payload?.data) ? payload.data : []
|
||||
return rows[0] || null
|
||||
})
|
||||
.then((row) => {
|
||||
if (cancelled || !row) return
|
||||
setTaskDetailChangeRequestNotice(buildTaskDetailChangeRequestNotice(row))
|
||||
})
|
||||
.catch(() => undefined)
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [material?.id, material?.canManageTaskDetailAssets])
|
||||
|
||||
useEffect(() => {
|
||||
// 监听滚动,控制返回顶部按钮显示
|
||||
const handleScroll = () => {
|
||||
@ -939,7 +1174,39 @@ export default function SharePage() {
|
||||
|
||||
// ---- 任务-详情单图编辑 ----
|
||||
|
||||
const getTaskDetailAssetGroup = (assetId: string): 'main' | 'sku' | 'detail' | null => {
|
||||
const assetType = material?.assets?.find((asset) => asset.id === assetId)?.assetType
|
||||
if (assetType === 'main-preview') return 'main'
|
||||
if (assetType === 'sku-preview') return 'sku'
|
||||
if (assetType === 'detail-preview') return 'detail'
|
||||
return null
|
||||
}
|
||||
|
||||
const getTaskDetailPendingOpGroup = (op: TaskDetailPendingOpInput | TaskDetailPendingOp) => {
|
||||
if (op.kind === 'append') return op.group
|
||||
if (op.kind === 'insert') return 'detail' as const
|
||||
return getTaskDetailAssetGroup(op.assetId)
|
||||
}
|
||||
|
||||
const canStartTaskDetailGroup = (group: 'main' | 'sku' | 'detail' | null) => {
|
||||
if (!group) {
|
||||
showToast('图片分组识别失败,请刷新后重试', { variant: 'error' })
|
||||
return false
|
||||
}
|
||||
const currentGroup = taskDetailPendingOps[0]
|
||||
? getTaskDetailPendingOpGroup(taskDetailPendingOps[0])
|
||||
: null
|
||||
if (currentGroup && currentGroup !== group) {
|
||||
showToast(`待提交清单已有${TASK_DETAIL_GROUP_LABELS[currentGroup]}变更,请先提交或清空后再编辑${TASK_DETAIL_GROUP_LABELS[group]}`, {
|
||||
variant: 'error',
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const handleTaskDetailReplaceRequest = (asset: { id: string }) => {
|
||||
if (!canStartTaskDetailGroup(getTaskDetailAssetGroup(asset.id))) return
|
||||
replaceTargetAssetIdRef.current = asset.id
|
||||
const input = replaceInputRef.current
|
||||
if (input) {
|
||||
@ -948,6 +1215,129 @@ export default function SharePage() {
|
||||
}
|
||||
}
|
||||
|
||||
const queueTaskDetailPendingOp = (
|
||||
nextOp: TaskDetailPendingOpInput,
|
||||
) => {
|
||||
if (!canStartTaskDetailGroup(getTaskDetailPendingOpGroup(nextOp))) return false
|
||||
setTaskDetailPendingOps((prev) => {
|
||||
const withKey: TaskDetailPendingOp = {
|
||||
...nextOp,
|
||||
key: `task-detail-op-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
} as TaskDetailPendingOp
|
||||
|
||||
if ('assetId' in nextOp) {
|
||||
return [
|
||||
...prev.filter((item) => !('assetId' in item) || item.assetId !== nextOp.assetId),
|
||||
withKey,
|
||||
]
|
||||
}
|
||||
|
||||
return [...prev, withKey]
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
const openTaskDetailAppendPicker = (group: TaskDetailAppendGroup) => {
|
||||
if (!canStartTaskDetailGroup(group)) return
|
||||
const input = taskDetailBatchInputRef.current
|
||||
taskDetailBatchIntentRef.current = { kind: 'append', group }
|
||||
if (input) {
|
||||
input.value = ''
|
||||
input.click()
|
||||
}
|
||||
}
|
||||
|
||||
const openTaskDetailInsertPicker = (intent: Omit<TaskDetailInsertIntent, 'kind'>) => {
|
||||
if (!canStartTaskDetailGroup('detail')) return
|
||||
const input = taskDetailBatchInputRef.current
|
||||
taskDetailBatchIntentRef.current = { kind: 'insert', ...intent }
|
||||
if (input) {
|
||||
input.value = ''
|
||||
input.click()
|
||||
}
|
||||
}
|
||||
|
||||
const queueTaskDetailUploads = async (files: File[], intent: TaskDetailBatchIntent) => {
|
||||
if (!material || files.length === 0) return
|
||||
|
||||
const uploadLabel = intent.kind === 'append'
|
||||
? `${TASK_DETAIL_GROUP_LABELS[intent.group]}追加图片`
|
||||
: '详情图插入图片'
|
||||
|
||||
setTaskDetailEditingAssetId(
|
||||
intent.kind === 'append'
|
||||
? `append-${intent.group}`
|
||||
: `insert-${intent.afterAssetId}-${intent.beforeAssetId}`,
|
||||
)
|
||||
|
||||
let queuedCount = 0
|
||||
let failedCount = 0
|
||||
|
||||
try {
|
||||
for (let index = 0; index < files.length; index += 1) {
|
||||
const file = files[index]
|
||||
if (file.size > TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes) {
|
||||
failedCount += 1
|
||||
showToast(
|
||||
`${file.name} 超过 ${Math.round(TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes / 1024 / 1024)}MB,已跳过`,
|
||||
{ variant: 'error' },
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await chunkUpload(file, {}, {
|
||||
skipComplete: true,
|
||||
onProgress: (progress) => setTaskDetailUploadProgress({
|
||||
label: `正在上传${uploadLabel} ${index + 1}/${files.length}`,
|
||||
progress,
|
||||
}),
|
||||
})
|
||||
const uploadId = (result as { uploadId?: string } | null)?.uploadId
|
||||
if (!uploadId) {
|
||||
throw new Error('上传未完成,请重试')
|
||||
}
|
||||
|
||||
if (intent.kind === 'append') {
|
||||
if (!queueTaskDetailPendingOp({
|
||||
kind: 'append',
|
||||
group: intent.group,
|
||||
uploadId,
|
||||
label: `追加到${TASK_DETAIL_GROUP_LABELS[intent.group]}末尾:${file.name}`,
|
||||
})) continue
|
||||
} else {
|
||||
if (!queueTaskDetailPendingOp({
|
||||
kind: 'insert',
|
||||
group: 'detail',
|
||||
uploadId,
|
||||
afterAssetId: intent.afterAssetId,
|
||||
beforeAssetId: intent.beforeAssetId,
|
||||
label: `插入到详情图之间:${file.name}`,
|
||||
})) continue
|
||||
}
|
||||
|
||||
queuedCount += 1
|
||||
} catch (error) {
|
||||
failedCount += 1
|
||||
showToast(error instanceof Error ? error.message : `${file.name} 上传失败,请稍后重试`, {
|
||||
variant: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (queuedCount > 0) {
|
||||
const successMessage = failedCount > 0
|
||||
? `已加入 ${queuedCount} 张,另有 ${failedCount} 张未进入待提交清单`
|
||||
: `${uploadLabel}已加入待提交清单`
|
||||
showToast(successMessage, { variant: 'success' })
|
||||
}
|
||||
} finally {
|
||||
setTaskDetailEditingAssetId(null)
|
||||
setTaskDetailUploadProgress(null)
|
||||
taskDetailBatchIntentRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const uploadAndReplaceTaskDetailImage = async (file: File) => {
|
||||
const targetAssetId = replaceTargetAssetIdRef.current
|
||||
if (!targetAssetId || !material) return
|
||||
@ -962,34 +1352,40 @@ export default function SharePage() {
|
||||
}
|
||||
|
||||
setTaskDetailEditingAssetId(targetAssetId)
|
||||
setReplaceUploadProgress(0)
|
||||
setTaskDetailUploadProgress({ label: '正在上传替换图片', progress: 0 })
|
||||
try {
|
||||
const result = await chunkUpload(file, {}, {
|
||||
skipComplete: true,
|
||||
onProgress: (progress) => setReplaceUploadProgress(progress),
|
||||
onProgress: (progress) => setTaskDetailUploadProgress({
|
||||
label: '正在上传替换图片',
|
||||
progress,
|
||||
}),
|
||||
})
|
||||
const uploadId = (result as { uploadId?: string } | null)?.uploadId
|
||||
if (!uploadId) {
|
||||
throw new Error('上传未完成,请重试')
|
||||
}
|
||||
|
||||
queueTaskDetailPendingOp({
|
||||
const queued = queueTaskDetailPendingOp({
|
||||
assetId: targetAssetId,
|
||||
kind: 'replace',
|
||||
label: `替换为 ${file.name}`,
|
||||
uploadId,
|
||||
})
|
||||
showToast('替换已加入待应用清单,全部操作完后点「应用变更」', { variant: 'success' })
|
||||
if (queued) {
|
||||
showToast('替换已加入待提交清单,全部操作完后点「提交主管确认」', { variant: 'success' })
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : '替换失败,请稍后重试', { variant: 'error' })
|
||||
} finally {
|
||||
setTaskDetailEditingAssetId(null)
|
||||
setReplaceUploadProgress(null)
|
||||
setTaskDetailUploadProgress(null)
|
||||
replaceTargetAssetIdRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const openTaskDetailRenameDialog = (asset: { id: string; fileName?: string }) => {
|
||||
if (!canStartTaskDetailGroup(getTaskDetailAssetGroup(asset.id))) return
|
||||
const currentName = asset.fileName || ''
|
||||
// 预览行文件名不是包内条目名:剥掉 .webp 与历史「源名-preview」尾巴,
|
||||
// 扩展名由服务端按包内条目自动保留,前端不预设(预设 .webp 会误触扩展名校验)。
|
||||
@ -1022,66 +1418,93 @@ export default function SharePage() {
|
||||
return
|
||||
}
|
||||
|
||||
queueTaskDetailPendingOp({
|
||||
const queued = queueTaskDetailPendingOp({
|
||||
assetId: renameDialogState.assetId,
|
||||
kind: 'rename',
|
||||
label: `重命名为 ${nextBaseName}${renameDialogState.extension}`,
|
||||
newFileName: nextBaseName,
|
||||
})
|
||||
showToast('重命名已加入待应用清单', { variant: 'success' })
|
||||
setRenameDialogState(null)
|
||||
if (queued) {
|
||||
showToast('重命名已加入待提交清单', { variant: 'success' })
|
||||
setRenameDialogState(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTaskDetailDeleteRequest = async (asset: { id: string; fileName?: string }) => {
|
||||
if (!material) return
|
||||
// 删除先进待应用清单(可撤销),与其他操作一起批量提交
|
||||
queueTaskDetailPendingOp({
|
||||
// 删除先进待提交清单(可撤销),与其他操作一起统一提交
|
||||
const queued = queueTaskDetailPendingOp({
|
||||
assetId: asset.id,
|
||||
kind: 'delete',
|
||||
label: `删除 ${asset.fileName || '未命名图片'}`,
|
||||
})
|
||||
showToast('删除已加入待应用清单,撤销请点清单中的 ×', { variant: 'success' })
|
||||
}
|
||||
|
||||
// 同一张图的新操作覆盖旧操作;清单按加入顺序提交
|
||||
const queueTaskDetailPendingOp = (op: {
|
||||
assetId: string
|
||||
kind: 'delete' | 'rename' | 'replace'
|
||||
label: string
|
||||
uploadId?: string
|
||||
newFileName?: string
|
||||
}) => {
|
||||
setTaskDetailPendingOps((prev) => [
|
||||
...prev.filter((item) => item.assetId !== op.assetId),
|
||||
{ ...op, key: `${op.assetId}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}` },
|
||||
])
|
||||
if (queued) {
|
||||
showToast('删除已加入待提交清单,撤销请点清单中的 ×', { variant: 'success' })
|
||||
}
|
||||
}
|
||||
|
||||
const applyTaskDetailPendingOps = async () => {
|
||||
if (!material || taskDetailPendingOps.length === 0 || taskDetailApplying) return
|
||||
setTaskDetailApplying(true)
|
||||
try {
|
||||
const response = await fetch(`/api/materials/${material.id}/assets/batch`, {
|
||||
const response = await fetch(`/api/materials/${material.id}/assets/change-requests`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
operations: taskDetailPendingOps.map((op) => ({
|
||||
kind: op.kind,
|
||||
assetId: op.assetId,
|
||||
...(op.kind === 'replace' ? { uploadId: op.uploadId } : {}),
|
||||
...(op.kind === 'rename' ? { newFileName: op.newFileName } : {}),
|
||||
})),
|
||||
operations: taskDetailPendingOps.map((op) => {
|
||||
switch (op.kind) {
|
||||
case 'append':
|
||||
return { kind: 'append', group: op.group, uploadId: op.uploadId }
|
||||
case 'insert':
|
||||
return {
|
||||
kind: 'insert',
|
||||
group: op.group,
|
||||
uploadId: op.uploadId,
|
||||
afterAssetId: op.afterAssetId,
|
||||
beforeAssetId: op.beforeAssetId,
|
||||
}
|
||||
case 'replace':
|
||||
return { kind: 'replace', assetId: op.assetId, uploadId: op.uploadId }
|
||||
case 'rename':
|
||||
return { kind: 'rename', assetId: op.assetId, newFileName: op.newFileName }
|
||||
case 'delete':
|
||||
return { kind: 'delete', assetId: op.assetId }
|
||||
}
|
||||
}),
|
||||
}),
|
||||
})
|
||||
const data = await response.json().catch(() => null)
|
||||
if (!response.ok || !data?.success) {
|
||||
throw new Error(data?.error || '应用变更失败,请稍后重试')
|
||||
throw new Error(data?.error || '提交变更失败,请稍后重试')
|
||||
}
|
||||
showToast(`已应用 ${taskDetailPendingOps.length} 项变更,下载包已同步更新`, { variant: 'success' })
|
||||
|
||||
const mode = String(data?.mode || data?.data?.mode || 'applied') === 'pending_approval'
|
||||
? 'pending_approval'
|
||||
: 'applied'
|
||||
|
||||
if (mode === 'pending_approval') {
|
||||
const notice = buildTaskDetailChangeRequestNotice({ ...data, mode }) || {
|
||||
requestId: null,
|
||||
mode: 'pending_approval' as const,
|
||||
status: 'pending_approval',
|
||||
managerName: null,
|
||||
expiresAt: null,
|
||||
}
|
||||
setTaskDetailChangeRequestNotice(notice)
|
||||
showToast(`已提交 ${taskDetailPendingOps.length} 项变更,等待${notice.managerName || '主管'}确认`, {
|
||||
variant: 'success',
|
||||
})
|
||||
} else {
|
||||
setTaskDetailChangeRequestNotice(null)
|
||||
showToast(`已直接应用 ${taskDetailPendingOps.length} 项变更,下载包已同步更新`, {
|
||||
variant: 'success',
|
||||
})
|
||||
await fetchMaterial()
|
||||
}
|
||||
|
||||
setTaskDetailPendingOps([])
|
||||
await fetchMaterial()
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : '应用变更失败,请稍后重试', { variant: 'error' })
|
||||
showToast(error instanceof Error ? error.message : '提交变更失败,请稍后重试', { variant: 'error' })
|
||||
} finally {
|
||||
setTaskDetailApplying(false)
|
||||
}
|
||||
@ -1172,13 +1595,28 @@ export default function SharePage() {
|
||||
{/* 任务-详情类型:使用专用详情组件 */}
|
||||
{mappedMaterialType === '任务-详情' ? (
|
||||
<div data-detail-preview className="mx-auto">
|
||||
{taskDetailChangeRequestNotice && (
|
||||
<TaskDetailChangeRequestBanner
|
||||
notice={taskDetailChangeRequestNotice}
|
||||
onOpenDetail={() => {
|
||||
if (taskDetailChangeRequestNotice.requestId) {
|
||||
router.push(`/task-detail-change-requests/${taskDetailChangeRequestNotice.requestId}`)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{Boolean(material.canManageTaskDetailAssets) && taskDetailPendingOps.length > 0 && (
|
||||
<div ref={taskDetailPendingBarRef} data-pending-bar className="mb-4 rounded-xl border border-[#e7e8ea] bg-white p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[13px] font-semibold text-[#1a1c1d]">
|
||||
待应用变更 {taskDetailPendingOps.length} 项
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-[13px] font-semibold text-[#1a1c1d]">
|
||||
待提交变更 {taskDetailPendingOps.length} 项
|
||||
</p>
|
||||
<p className="mt-1 text-[12px] leading-5 text-[#6b7075]">
|
||||
替换和插入文件先留在临时区,点击提交后再进入主管确认,不会立即覆盖正式素材。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={taskDetailApplying}
|
||||
@ -1192,9 +1630,9 @@ export default function SharePage() {
|
||||
disabled={taskDetailApplying}
|
||||
data-apply-btn
|
||||
onClick={() => void applyTaskDetailPendingOps()}
|
||||
className="rounded-lg bg-[#1a1c1d] px-4 py-1.5 text-[12px] font-medium text-white transition-all hover:opacity-80 active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="min-h-10 rounded-lg bg-[#1a1c1d] px-4 py-1.5 text-[12px] font-medium text-white transition-all hover:opacity-80 active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{taskDetailApplying ? '正在应用…' : `应用 ${taskDetailPendingOps.length} 项变更`}
|
||||
{taskDetailApplying ? '正在提交…' : `提交主管确认 ${taskDetailPendingOps.length} 项`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -1207,7 +1645,15 @@ export default function SharePage() {
|
||||
? 'mr-2 text-[#b42318]'
|
||||
: 'mr-2 text-[#6b7075]'
|
||||
}>
|
||||
{op.kind === 'delete' ? '删除' : op.kind === 'rename' ? '改名' : '替换'}
|
||||
{op.kind === 'delete'
|
||||
? '删除'
|
||||
: op.kind === 'rename'
|
||||
? '改名'
|
||||
: op.kind === 'replace'
|
||||
? '替换'
|
||||
: op.kind === 'append'
|
||||
? '追加'
|
||||
: '插入'}
|
||||
</span>
|
||||
{op.label}
|
||||
</span>
|
||||
@ -1242,10 +1688,12 @@ export default function SharePage() {
|
||||
showToast={showToast}
|
||||
canManageAssets={Boolean(material.canManageTaskDetailAssets)}
|
||||
editingAssetId={taskDetailEditingAssetId}
|
||||
pendingAssetIds={taskDetailPendingOps.map((op) => op.assetId)}
|
||||
pendingAssetIds={taskDetailPendingOps.flatMap((op) => ('assetId' in op ? [op.assetId] : []))}
|
||||
onRequestReplace={handleTaskDetailReplaceRequest}
|
||||
onRequestRename={openTaskDetailRenameDialog}
|
||||
onRequestDelete={(asset) => void handleTaskDetailDeleteRequest(asset)}
|
||||
onRequestAppend={(group) => openTaskDetailAppendPicker(group)}
|
||||
onRequestInsert={(position) => openTaskDetailInsertPicker(position)}
|
||||
onDownload={async (type, assetId) => {
|
||||
try {
|
||||
let directDownloadUrl = `/api/materials/download?id=${material.id}&type=${type}`
|
||||
@ -1810,6 +2258,7 @@ export default function SharePage() {
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
data-testid="task-detail-replace-input"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (file) {
|
||||
@ -1819,6 +2268,23 @@ export default function SharePage() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<input
|
||||
ref={taskDetailBatchInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
data-testid="task-detail-batch-input"
|
||||
onChange={(event) => {
|
||||
const files = Array.from(event.target.files || [])
|
||||
const intent = taskDetailBatchIntentRef.current
|
||||
if (intent && files.length > 0) {
|
||||
void queueTaskDetailUploads(files, intent)
|
||||
}
|
||||
event.target.value = ''
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 任务-详情:重命名弹窗 */}
|
||||
{renameDialogState && (
|
||||
<div
|
||||
@ -1836,7 +2302,7 @@ export default function SharePage() {
|
||||
>
|
||||
<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
|
||||
@ -1857,7 +2323,7 @@ export default function SharePage() {
|
||||
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]">
|
||||
保存后自动沿用原扩展名
|
||||
提交后自动沿用原扩展名
|
||||
</span>
|
||||
</div>
|
||||
{renameDialogState.error && (
|
||||
@ -1878,7 +2344,7 @@ export default function SharePage() {
|
||||
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 ? '正在保存…' : '保存并重打包'}
|
||||
{renameDialogState.saving ? '正在加入…' : '加入待提交清单'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -1887,8 +2353,8 @@ export default function SharePage() {
|
||||
|
||||
{/* 统一的Toast提示 */}
|
||||
{toast && toast.show && (
|
||||
<ToastBubble message={replaceUploadProgress !== null
|
||||
? `正在上传替换图片… ${replaceUploadProgress}%`
|
||||
<ToastBubble message={taskDetailUploadProgress
|
||||
? `${taskDetailUploadProgress.label}… ${taskDetailUploadProgress.progress}%`
|
||||
: toast.message}
|
||||
variant={toast.variant} />
|
||||
)}
|
||||
|
||||
85
app/task-detail-change-requests/[id]/page.test.tsx
Normal file
85
app/task-detail-change-requests/[id]/page.test.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
||||
import TaskDetailChangeRequestPage from './page'
|
||||
|
||||
const originalFetch = global.fetch
|
||||
|
||||
const createJsonResponse = <T,>(body: T, status = 200) => ({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
})
|
||||
|
||||
jest.mock('next/navigation', () => ({
|
||||
useParams: () => ({ id: 'request-1' }),
|
||||
}))
|
||||
|
||||
describe('TaskDetailChangeRequestPage', () => {
|
||||
afterAll(() => {
|
||||
global.fetch = originalFetch
|
||||
})
|
||||
|
||||
test('loads request details and allows approver to approve', async () => {
|
||||
const user = userEvent.setup()
|
||||
let approved = false
|
||||
const fetchMock = jest.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input)
|
||||
if (url === '/api/task-detail-change-requests/request-1') {
|
||||
return createJsonResponse({
|
||||
success: true,
|
||||
data: {
|
||||
id: 'request-1',
|
||||
status: approved ? 'applied' : 'pending_manager_approval',
|
||||
canDecide: !approved,
|
||||
managerName: '王主管',
|
||||
requesterName: '小李',
|
||||
expiresAt: '2026-09-01T12:00:00.000Z',
|
||||
decidedAt: approved ? '2026-08-28T10:30:00.000Z' : null,
|
||||
material: {
|
||||
productModel: 'GBSFY-SJZ0024详情',
|
||||
materialType: '任务-详情',
|
||||
taskNo: 'TASK-001',
|
||||
},
|
||||
operations: [
|
||||
{ kind: 'append', group: 'main', fileName: 'append-main-1.jpg' },
|
||||
{ kind: 'insert', group: 'detail', afterAssetId: 'a1', beforeAssetId: 'a2', fileName: 'insert-detail.jpg' },
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (url === '/api/task-detail-change-requests/request-1/decision') {
|
||||
expect(init).toMatchObject({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ decision: 'approve' }),
|
||||
})
|
||||
|
||||
approved = true
|
||||
return createJsonResponse({ success: true, data: { status: 'applied' } })
|
||||
}
|
||||
|
||||
return createJsonResponse({ error: 'unexpected request' }, 500)
|
||||
})
|
||||
global.fetch = fetchMock as jest.Mock
|
||||
|
||||
render(<TaskDetailChangeRequestPage />)
|
||||
|
||||
expect(await screen.findByRole('heading', { name: '审批详情' })).toBeInTheDocument()
|
||||
expect(await screen.findByText('GBSFY-SJZ0024详情')).toBeInTheDocument()
|
||||
expect(screen.getByText('小李')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('待主管确认').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('追加到主图末尾')).toBeInTheDocument()
|
||||
expect(screen.getByText('插入到详情图接缝(a1 → a2)')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '批准' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('已通过').length).toBeGreaterThan(0)
|
||||
})
|
||||
expect(screen.getByText('处理时间')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '批准' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '否决' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
393
app/task-detail-change-requests/[id]/page.tsx
Normal file
393
app/task-detail-change-requests/[id]/page.tsx
Normal file
@ -0,0 +1,393 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
type TaskDetailChangeRequestDecision = 'approve' | 'reject'
|
||||
|
||||
type TaskDetailChangeRequestStatus = 'pending_approval' | 'approved' | 'rejected' | 'expired' | 'apply_failed'
|
||||
|
||||
type TaskDetailChangeRequestOperation = {
|
||||
kind: string
|
||||
label?: string | null
|
||||
assetId?: string | null
|
||||
group?: 'main' | 'sku' | 'detail' | null
|
||||
afterAssetId?: string | null
|
||||
beforeAssetId?: string | null
|
||||
newFileName?: string | null
|
||||
}
|
||||
|
||||
type TaskDetailChangeRequestViewModel = {
|
||||
id: string
|
||||
status: TaskDetailChangeRequestStatus
|
||||
canDecide: boolean
|
||||
managerName: string | null
|
||||
requesterName: string | null
|
||||
requesterHandle: string | null
|
||||
materialTitle: string
|
||||
materialType: string | null
|
||||
taskNo: string | null
|
||||
expiresAt: string | null
|
||||
decidedAt: string | null
|
||||
operations: TaskDetailChangeRequestOperation[]
|
||||
}
|
||||
|
||||
const STATUS_META: Record<TaskDetailChangeRequestStatus, { label: string; className: string }> = {
|
||||
pending_approval: {
|
||||
label: '待主管确认',
|
||||
className: 'border-[#b8d0ff] bg-[#edf4ff] text-[#0052d9]',
|
||||
},
|
||||
approved: {
|
||||
label: '已通过',
|
||||
className: 'border-[#b9ead0] bg-[#ecfdf3] text-[#087a3c]',
|
||||
},
|
||||
rejected: {
|
||||
label: '已否决',
|
||||
className: 'border-[#f3d29b] bg-[#fff8e8] text-[#8a5200]',
|
||||
},
|
||||
expired: {
|
||||
label: '已超时',
|
||||
className: 'border-[#e2e8f0] bg-[#f8fafc] text-[#66758b]',
|
||||
},
|
||||
apply_failed: {
|
||||
label: '应用失败',
|
||||
className: 'border-[#f5b9b9] bg-[#fff1f1] text-[#b42318]',
|
||||
},
|
||||
}
|
||||
|
||||
const GROUP_LABELS: Record<'main' | 'sku' | 'detail', string> = {
|
||||
main: '主图',
|
||||
sku: 'SKU 图',
|
||||
detail: '详情图',
|
||||
}
|
||||
|
||||
function readText(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null
|
||||
}
|
||||
|
||||
function readNestedName(record: Record<string, unknown>, key: string) {
|
||||
const direct = readText(record[key])
|
||||
if (direct) return direct
|
||||
const nested = record[key] as Record<string, unknown> | null
|
||||
if (nested && typeof nested === 'object') {
|
||||
return readText(nested.name) || readText(nested.displayName)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeStatus(value: unknown): TaskDetailChangeRequestStatus {
|
||||
const raw = String(value || '').trim().toLowerCase()
|
||||
if (raw === 'approved' || raw === 'approve' || raw === 'applied') return 'approved'
|
||||
if (raw === 'rejected' || raw === 'reject') return 'rejected'
|
||||
if (raw === 'expired' || raw === 'timeout' || raw === 'timed_out') return 'expired'
|
||||
if (raw === 'apply_failed') return 'apply_failed'
|
||||
return 'pending_approval'
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null) {
|
||||
if (!value) return '未提供'
|
||||
const parsed = new Date(value)
|
||||
if (Number.isNaN(parsed.getTime())) return value
|
||||
return format(parsed, 'yyyy-MM-dd HH:mm')
|
||||
}
|
||||
|
||||
function normalizeChangeRequestPayload(payload: unknown): TaskDetailChangeRequestViewModel | null {
|
||||
if (!payload || typeof payload !== 'object') return null
|
||||
|
||||
const root = payload as Record<string, unknown>
|
||||
const candidate =
|
||||
(root.data as Record<string, unknown> | undefined)?.changeRequest
|
||||
|| root.changeRequest
|
||||
|| root.data
|
||||
|| root
|
||||
|
||||
if (!candidate || typeof candidate !== 'object') return null
|
||||
|
||||
const record = candidate as Record<string, unknown>
|
||||
const material = (record.material as Record<string, unknown> | undefined) || {}
|
||||
const requester = (record.requester as Record<string, unknown> | undefined)
|
||||
|| (record.submittedBy as Record<string, unknown> | undefined)
|
||||
|| {}
|
||||
const operations = Array.isArray(record.operations)
|
||||
? record.operations.map((item) => {
|
||||
const op = (item || {}) as Record<string, unknown>
|
||||
return {
|
||||
kind: String(op.kind || ''),
|
||||
label: readText(op.label),
|
||||
assetId: readText(op.assetId),
|
||||
group: (readText(op.group) as 'main' | 'sku' | 'detail' | null) || null,
|
||||
afterAssetId: readText(op.afterAssetId),
|
||||
beforeAssetId: readText(op.beforeAssetId),
|
||||
newFileName: readText(op.newFileName),
|
||||
}
|
||||
})
|
||||
: []
|
||||
|
||||
return {
|
||||
id: readText(record.id) || readText(record.requestId) || 'unknown-request',
|
||||
status: normalizeStatus(record.status || record.reviewStatus || root.status),
|
||||
canDecide: Boolean(record.canDecide),
|
||||
managerName:
|
||||
readNestedName(record, 'manager')
|
||||
|| readNestedName(record, 'approver')
|
||||
|| readNestedName(record, 'reviewer')
|
||||
|| readText(record.managerName)
|
||||
|| readText(record.approverName)
|
||||
|| readText(record.reviewerName),
|
||||
requesterName:
|
||||
readNestedName(requester, 'profile')
|
||||
|| readText(requester.displayName)
|
||||
|| readText(requester.realName)
|
||||
|| readText(requester.name)
|
||||
|| readText(record.requesterName),
|
||||
requesterHandle: readText(requester.username) || readText(requester.handle),
|
||||
materialTitle:
|
||||
readText(material.title)
|
||||
|| readText(material.displayTitle)
|
||||
|| readText(material.productModel)
|
||||
|| readText(record.materialTitle)
|
||||
|| '未命名素材',
|
||||
materialType: readText(material.materialType) || readText(record.materialType),
|
||||
taskNo: readText(material.taskNo) || readText(record.taskNo),
|
||||
expiresAt: readText(record.expiresAt) || readText(record.deadlineAt) || readText(record.expireAt),
|
||||
decidedAt: readText(record.decidedAt) || readText(record.reviewedAt),
|
||||
operations,
|
||||
}
|
||||
}
|
||||
|
||||
function describeOperation(op: TaskDetailChangeRequestOperation, index: number) {
|
||||
if (op.label) return op.label
|
||||
|
||||
if (op.kind === 'append') {
|
||||
const groupLabel = GROUP_LABELS[op.group || 'main']
|
||||
return `追加到${groupLabel}末尾`
|
||||
}
|
||||
if (op.kind === 'insert') {
|
||||
return `插入到详情图接缝(${op.afterAssetId || '前图'} → ${op.beforeAssetId || '后图'})`
|
||||
}
|
||||
if (op.kind === 'replace') {
|
||||
return `替换图片 ${op.assetId || `#${index + 1}`}`
|
||||
}
|
||||
if (op.kind === 'rename') {
|
||||
return `重命名为 ${op.newFileName || '新文件名'}`
|
||||
}
|
||||
if (op.kind === 'delete') {
|
||||
return `删除图片 ${op.assetId || `#${index + 1}`}`
|
||||
}
|
||||
|
||||
return `操作 ${index + 1}`
|
||||
}
|
||||
|
||||
export default function TaskDetailChangeRequestPage() {
|
||||
const params = useParams()
|
||||
const requestId = params?.id as string
|
||||
const [record, setRecord] = useState<TaskDetailChangeRequestViewModel | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busyDecision, setBusyDecision] = useState<TaskDetailChangeRequestDecision | null>(null)
|
||||
|
||||
const loadRequest = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const response = await fetch(`/api/task-detail-change-requests/${requestId}`, {
|
||||
cache: 'no-store',
|
||||
})
|
||||
const payload = await response.json().catch(() => null)
|
||||
if (!response.ok) {
|
||||
throw new Error((payload as { error?: string } | null)?.error || '加载审批详情失败')
|
||||
}
|
||||
const next = normalizeChangeRequestPayload(payload)
|
||||
if (!next) throw new Error('审批详情格式不正确')
|
||||
setRecord(next)
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : '加载审批详情失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [requestId])
|
||||
|
||||
useEffect(() => {
|
||||
if (requestId) {
|
||||
void loadRequest()
|
||||
}
|
||||
}, [requestId, loadRequest])
|
||||
|
||||
const submitDecision = async (decision: TaskDetailChangeRequestDecision) => {
|
||||
if (!record || busyDecision) return
|
||||
|
||||
try {
|
||||
setBusyDecision(decision)
|
||||
setError(null)
|
||||
const response = await fetch(`/api/task-detail-change-requests/${requestId}/decision`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ decision }),
|
||||
})
|
||||
const payload = await response.json().catch(() => null)
|
||||
if (!response.ok) {
|
||||
throw new Error((payload as { error?: string } | null)?.error || '提交审批结果失败')
|
||||
}
|
||||
await loadRequest()
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : '提交审批结果失败')
|
||||
} finally {
|
||||
setBusyDecision(null)
|
||||
}
|
||||
}
|
||||
|
||||
const statusMeta = STATUS_META[record?.status || 'pending_approval']
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#fafafa] text-[#1a1c1d]">
|
||||
<main className="mx-auto max-w-5xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<header className="mb-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.12em] text-[#66758b]">任务详情变更</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-[28px] font-semibold tracking-[-0.03em] text-[#1a1c1d]">审批详情</h1>
|
||||
{!loading && record && (
|
||||
<span className={`inline-flex rounded-full border px-2.5 py-1 text-[11px] font-bold ${statusMeta.className}`}>
|
||||
{statusMeta.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 max-w-3xl text-[14px] leading-6 text-[#6b7075]">
|
||||
这里显示提交人、素材信息、操作清单和到期时间。主管确认前,正式素材不会被直接覆盖。
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-xl border border-[#f5c2c0] bg-[#fff5f4] px-4 py-3 text-[13px] text-[#b42318]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="rounded-xl border border-[#e7e8ea] bg-white px-5 py-10 text-sm text-[#6b7075]">
|
||||
正在加载审批详情…
|
||||
</div>
|
||||
) : !record ? (
|
||||
<div className="rounded-xl border border-[#e7e8ea] bg-white px-5 py-10 text-sm text-[#6b7075]">
|
||||
未找到这条审批记录。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1.4fr)_minmax(18rem,1fr)]">
|
||||
<section className="rounded-xl border border-[#e7e8ea] bg-white p-5">
|
||||
<h2 className="text-[15px] font-semibold tracking-[-0.03em] text-[#1a1c1d]">审批摘要</h2>
|
||||
<dl className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-[12px] text-[#86898e]">素材</dt>
|
||||
<dd className="mt-1 text-[14px] font-medium text-[#1a1c1d]">{record.materialTitle}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[12px] text-[#86898e]">素材类型</dt>
|
||||
<dd className="mt-1 text-[14px] font-medium text-[#1a1c1d]">{record.materialType || '未提供'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[12px] text-[#86898e]">任务号</dt>
|
||||
<dd className="mt-1 text-[14px] font-medium text-[#1a1c1d]">{record.taskNo || '未提供'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[12px] text-[#86898e]">提交人</dt>
|
||||
<dd className="mt-1 text-[14px] font-medium text-[#1a1c1d]">
|
||||
{record.requesterName || '未提供'}
|
||||
{record.requesterHandle ? (
|
||||
<span className="ml-2 text-[12px] font-normal text-[#86898e]">@{record.requesterHandle}</span>
|
||||
) : null}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<aside className="rounded-xl border border-[#e7e8ea] bg-white p-5">
|
||||
<h2 className="text-[15px] font-semibold tracking-[-0.03em] text-[#1a1c1d]">处理状态</h2>
|
||||
<dl className="mt-4 space-y-4">
|
||||
<div>
|
||||
<dt className="text-[12px] text-[#86898e]">当前状态</dt>
|
||||
<dd className="mt-1 text-[14px] font-medium text-[#1a1c1d]">{statusMeta.label}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[12px] text-[#86898e]">审批经理</dt>
|
||||
<dd className="mt-1 text-[14px] font-medium text-[#1a1c1d]">{record.managerName || '待分配'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[12px] text-[#86898e]">到期时间</dt>
|
||||
<dd className="mt-1 text-[14px] font-medium text-[#1a1c1d]">{formatDateTime(record.expiresAt)}</dd>
|
||||
</div>
|
||||
{record.decidedAt && (
|
||||
<div>
|
||||
<dt className="text-[12px] text-[#86898e]">处理时间</dt>
|
||||
<dd className="mt-1 text-[14px] font-medium text-[#1a1c1d]">{formatDateTime(record.decidedAt)}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{record.canDecide && record.status === 'pending_approval' && (
|
||||
<div className="mt-5 flex flex-col gap-2 sm:flex-row">
|
||||
<button
|
||||
type="button"
|
||||
disabled={Boolean(busyDecision)}
|
||||
onClick={() => void submitDecision('approve')}
|
||||
className="inline-flex min-h-11 flex-1 items-center justify-center rounded-[8px] bg-[#1a1c1d] px-4 text-[13px] font-medium text-white transition-colors hover:bg-[#2a2d2f] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 disabled:opacity-50"
|
||||
>
|
||||
{busyDecision === 'approve' ? '正在批准…' : '批准'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={Boolean(busyDecision)}
|
||||
onClick={() => void submitDecision('reject')}
|
||||
className="inline-flex min-h-11 flex-1 items-center justify-center rounded-[8px] border border-[#e7e8ea] bg-white px-4 text-[13px] font-medium text-[#1a1c1d] transition-colors hover:bg-[#fafafa] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 disabled:opacity-50"
|
||||
>
|
||||
{busyDecision === 'reject' ? '正在否决…' : '否决'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<section className="rounded-xl border border-[#e7e8ea] bg-white p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="text-[15px] font-semibold tracking-[-0.03em] text-[#1a1c1d]">操作清单</h2>
|
||||
<span className="text-[12px] text-[#86898e]">{record.operations.length} 项</span>
|
||||
</div>
|
||||
{record.operations.length === 0 ? (
|
||||
<p className="mt-4 text-[14px] text-[#6b7075]">这条审批记录还没有操作项。</p>
|
||||
) : (
|
||||
<ol className="mt-4 space-y-2">
|
||||
{record.operations.map((op, index) => (
|
||||
<li
|
||||
key={`${op.kind}-${op.assetId || op.afterAssetId || index}`}
|
||||
className="rounded-[10px] border border-[#e7e8ea] bg-[#fafafa] px-4 py-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13px] font-medium text-[#1a1c1d]">{describeOperation(op, index)}</p>
|
||||
<p className="mt-1 text-[12px] text-[#86898e]">
|
||||
{op.kind === 'append'
|
||||
? '追加图片会在主管通过后追加到分组末尾'
|
||||
: op.kind === 'insert'
|
||||
? '插入图片会按当前接缝位置进入待处理链路'
|
||||
: op.kind === 'replace'
|
||||
? '替换图片先写入临时区,等待主管确认'
|
||||
: op.kind === 'rename'
|
||||
? '仅修改文件名,不会改变扩展名'
|
||||
: '删除会在主管通过后从正式素材中移除'}
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-[#d7dde3] bg-white px-2 py-0.5 text-[11px] font-medium text-[#66758b]">
|
||||
{op.kind}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
78
components/detail/ProductPackageDetail.test.tsx
Normal file
78
components/detail/ProductPackageDetail.test.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
||||
import ProductPackageDetail from './ProductPackageDetail'
|
||||
|
||||
const material = {
|
||||
id: 'material-1',
|
||||
taskNo: 'TASK-001',
|
||||
productModel: 'GBSFY-SJZ0024',
|
||||
categoryName: '顾家',
|
||||
sceneName: '客厅',
|
||||
taskStatus: 'ready',
|
||||
assets: [
|
||||
{ id: 'main-1', assetType: 'main-preview', fileUrl: '/main-1.jpg', fileName: 'main-1.jpg', fileSize: 100, sortOrder: 0 },
|
||||
{ id: 'main-2', assetType: 'main-preview', fileUrl: '/main-2.jpg', fileName: 'main-2.jpg', fileSize: 100, sortOrder: 1 },
|
||||
{ id: 'sku-1', assetType: 'sku-preview', fileUrl: '/sku-1.jpg', fileName: 'sku-1.jpg', fileSize: 100, sortOrder: 0 },
|
||||
{ id: 'sku-2', assetType: 'sku-preview', fileUrl: '/sku-2.jpg', fileName: 'sku-2.jpg', fileSize: 100, sortOrder: 1 },
|
||||
{ id: 'detail-1', assetType: 'detail-preview', fileUrl: '/detail-1.jpg', fileName: 'detail-1.jpg', fileSize: 100, sortOrder: 0 },
|
||||
{ id: 'detail-2', assetType: 'detail-preview', fileUrl: '/detail-2.jpg', fileName: 'detail-2.jpg', fileSize: 100, sortOrder: 1 },
|
||||
{ id: 'detail-3', assetType: 'detail-preview', fileUrl: '/detail-3.jpg', fileName: 'detail-3.jpg', fileSize: 100, sortOrder: 2 },
|
||||
],
|
||||
}
|
||||
|
||||
describe('ProductPackageDetail task-detail editing affordances', () => {
|
||||
test('renders append and insert controls with exact positions', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onRequestAppend = jest.fn()
|
||||
const onRequestInsert = jest.fn()
|
||||
|
||||
render(
|
||||
<ProductPackageDetail
|
||||
material={material}
|
||||
canDownloadSource={false}
|
||||
onDownload={jest.fn()}
|
||||
canManageAssets
|
||||
onRequestReplace={jest.fn()}
|
||||
onRequestRename={jest.fn()}
|
||||
onRequestDelete={jest.fn()}
|
||||
onRequestAppend={onRequestAppend}
|
||||
onRequestInsert={onRequestInsert}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: '编辑图片 main-1.jpg' })).toHaveClass('opacity-100')
|
||||
expect(screen.getByRole('button', { name: '追加图片,加入主图末尾' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '追加图片,加入 SKU 末尾' })).toBeInTheDocument()
|
||||
|
||||
const insertButtons = screen.getAllByRole('button', { name: /在详情图 \d 和详情图 \d 之间插入图片/ })
|
||||
expect(insertButtons).toHaveLength(2)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '追加图片,加入主图末尾' }))
|
||||
expect(onRequestAppend).toHaveBeenCalledWith('main')
|
||||
|
||||
await user.click(insertButtons[0])
|
||||
expect(onRequestInsert).toHaveBeenCalledWith({
|
||||
group: 'detail',
|
||||
afterAssetId: 'detail-1',
|
||||
beforeAssetId: 'detail-2',
|
||||
})
|
||||
})
|
||||
|
||||
test('empty main and sku groups still expose their append controls', () => {
|
||||
render(
|
||||
<ProductPackageDetail
|
||||
material={{ ...material, assets: material.assets.filter((asset) => asset.assetType === 'detail-preview') }}
|
||||
canDownloadSource={false}
|
||||
onDownload={jest.fn()}
|
||||
canManageAssets
|
||||
onRequestAppend={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('主图 (0)')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '追加图片,加入主图末尾' })).toBeInTheDocument()
|
||||
expect(screen.getByText('SKU图 (0)')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '追加图片,加入 SKU 末尾' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -27,6 +27,14 @@ interface Asset {
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
type AppendableGroup = 'main' | 'sku';
|
||||
|
||||
type DetailInsertPosition = {
|
||||
group: 'detail';
|
||||
afterAssetId: string;
|
||||
beforeAssetId: string;
|
||||
};
|
||||
|
||||
interface ProductPackageDetailProps {
|
||||
material: {
|
||||
id: string;
|
||||
@ -51,10 +59,71 @@ interface ProductPackageDetailProps {
|
||||
onRequestReplace?: (asset: Asset) => void;
|
||||
onRequestRename?: (asset: Asset) => void;
|
||||
onRequestDelete?: (asset: Asset) => void;
|
||||
/** 已加入待应用清单的资产 id,缩略图上显示待应用标记 */
|
||||
onRequestAppend?: (group: AppendableGroup) => void;
|
||||
onRequestInsert?: (position: DetailInsertPosition) => void;
|
||||
/** 已加入待提交清单的资产 id,缩略图上显示待提交标记 */
|
||||
pendingAssetIds?: string[];
|
||||
}
|
||||
|
||||
function AddImageButton({
|
||||
label,
|
||||
hint,
|
||||
onClick,
|
||||
disabled,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
hint: string;
|
||||
onClick: () => void;
|
||||
disabled: boolean;
|
||||
className: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
aria-label={`${label},${hint}`}
|
||||
className={`${className} flex items-center justify-center rounded-[8px] border border-dashed border-[#cfd6dd] bg-[#fafafa] text-center text-[#4f6077] transition-colors hover:border-[#1a1c1d] hover:bg-white hover:text-[#1a1c1d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50`}
|
||||
>
|
||||
<span className="flex flex-col items-center gap-1 px-2">
|
||||
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="none" stroke="currentColor" aria-hidden="true">
|
||||
<path d="M10 4v12M4 10h12" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.6" />
|
||||
</svg>
|
||||
<span className="text-[12px] font-medium tracking-[-0.02em]">{label}</span>
|
||||
<span className="text-[10px] leading-4 text-[#86898e]">{hint}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailInsertButton({
|
||||
disabled,
|
||||
onClick,
|
||||
afterIndex,
|
||||
beforeIndex,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
onClick: () => void;
|
||||
afterIndex: number;
|
||||
beforeIndex: number;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
aria-label={`在详情图 ${afterIndex} 和详情图 ${beforeIndex} 之间插入图片`}
|
||||
className="my-2 flex w-full items-center justify-center gap-2 rounded-[8px] border border-dashed border-[#cfd6dd] bg-[#fafafa] px-3 py-2 text-[12px] font-medium tracking-[-0.02em] text-[#4f6077] transition-colors hover:border-[#1a1c1d] hover:bg-white hover:text-[#1a1c1d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="none" stroke="currentColor" aria-hidden="true">
|
||||
<path d="M10 4v12M4 10h12" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.6" />
|
||||
</svg>
|
||||
<span>在这里插入图片</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetEditMenu({
|
||||
asset,
|
||||
busy,
|
||||
@ -85,7 +154,7 @@ function AssetEditMenu({
|
||||
<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"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-[6px] border border-[#e7e8ea] bg-white/95 text-[#1a1c1d] opacity-100 transition-opacity hover:border-[#1a1c1d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-1 md:opacity-0 md:group-hover:opacity-100 md:focus-visible:opacity-100"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@ -149,6 +218,8 @@ export default function ProductPackageDetail({
|
||||
onRequestReplace,
|
||||
onRequestRename,
|
||||
onRequestDelete,
|
||||
onRequestAppend,
|
||||
onRequestInsert,
|
||||
pendingAssetIds = [],
|
||||
}: ProductPackageDetailProps) {
|
||||
// 当前预览的图片类型和索引
|
||||
@ -258,7 +329,7 @@ export default function ProductPackageDetail({
|
||||
)}
|
||||
|
||||
{/* 主图小卡片展示区 */}
|
||||
{mainImages.length > 0 && (
|
||||
{(mainImages.length > 0 || (canManageAssets && onRequestAppend)) && (
|
||||
<div className={`${detailPanelClass} p-4`}>
|
||||
<h4 className={detailSectionTitleClass}>主图 ({mainImages.length})</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@ -320,12 +391,21 @@ export default function ProductPackageDetail({
|
||||
<span className="pointer-events-none absolute inset-0 z-0 animate-pulse rounded-[8px] border border-[#1a1c1d]/30 bg-white/60" />
|
||||
)}
|
||||
{pendingAssetIds.includes(img.id) && (
|
||||
<span className="pointer-events-none absolute left-1 top-1 z-10 rounded bg-[#1a1c1d]/80 px-1.5 py-0.5 text-[10px] font-medium text-white">待应用</span>
|
||||
<span className="pointer-events-none absolute left-1 top-1 z-10 rounded bg-[#1a1c1d]/80 px-1.5 py-0.5 text-[10px] font-medium text-white">待提交</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{canManageAssets && onRequestAppend && (
|
||||
<AddImageButton
|
||||
label="追加图片"
|
||||
hint="加入主图末尾"
|
||||
disabled={Boolean(editingAssetId)}
|
||||
onClick={() => onRequestAppend('main')}
|
||||
className="h-[84px] w-[84px] flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -336,35 +416,49 @@ export default function ProductPackageDetail({
|
||||
<h4 className={detailSectionTitleClass}>详情图 ({detailImages.length})</h4>
|
||||
<div className="space-y-0">
|
||||
{detailImages.map((img, index) => (
|
||||
<div key={img.id} className="group relative">
|
||||
<img
|
||||
src={img.fileUrl}
|
||||
alt={`详情图 ${index + 1}`}
|
||||
className="w-full block"
|
||||
style={{ display: 'block' }}
|
||||
/>
|
||||
<div className="absolute left-2 top-2 bg-[#1a1c1d] px-2 py-1 text-[11px] text-white">
|
||||
{index + 1}
|
||||
<div key={img.id}>
|
||||
<div className="group relative">
|
||||
<img
|
||||
src={img.fileUrl}
|
||||
alt={`详情图 ${index + 1}`}
|
||||
className="w-full block"
|
||||
style={{ display: 'block' }}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
{pendingAssetIds.includes(img.id) && (
|
||||
<span className="pointer-events-none absolute left-1 top-1 z-10 rounded bg-[#1a1c1d]/80 px-1.5 py-0.5 text-[10px] font-medium text-white">待提交</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
{pendingAssetIds.includes(img.id) && (
|
||||
<span className="pointer-events-none absolute left-1 top-1 z-10 rounded bg-[#1a1c1d]/80 px-1.5 py-0.5 text-[10px] font-medium text-white">待应用</span>
|
||||
)}
|
||||
</>
|
||||
{canManageAssets && onRequestInsert && detailImages[index + 1] && (
|
||||
<DetailInsertButton
|
||||
disabled={Boolean(editingAssetId)}
|
||||
afterIndex={index + 1}
|
||||
beforeIndex={index + 2}
|
||||
onClick={() => onRequestInsert({
|
||||
group: 'detail',
|
||||
afterAssetId: img.id,
|
||||
beforeAssetId: detailImages[index + 1].id,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@ -413,7 +507,7 @@ export default function ProductPackageDetail({
|
||||
</div>
|
||||
|
||||
{/* SKU图小卡片展示区 */}
|
||||
{skuImages.length > 0 && (
|
||||
{(skuImages.length > 0 || (canManageAssets && onRequestAppend)) && (
|
||||
<div className={`${detailPanelClass} p-4`}>
|
||||
<h4 className={detailSectionTitleClass}>SKU图 ({skuImages.length})</h4>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
@ -461,12 +555,21 @@ export default function ProductPackageDetail({
|
||||
<span className="pointer-events-none absolute inset-0 z-0 animate-pulse rounded-[8px] border border-[#1a1c1d]/30 bg-white/60" />
|
||||
)}
|
||||
{pendingAssetIds.includes(img.id) && (
|
||||
<span className="pointer-events-none absolute left-1 top-1 z-10 rounded bg-[#1a1c1d]/80 px-1.5 py-0.5 text-[10px] font-medium text-white">待应用</span>
|
||||
<span className="pointer-events-none absolute left-1 top-1 z-10 rounded bg-[#1a1c1d]/80 px-1.5 py-0.5 text-[10px] font-medium text-white">待提交</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{canManageAssets && onRequestAppend && (
|
||||
<AddImageButton
|
||||
label="追加图片"
|
||||
hint="加入 SKU 末尾"
|
||||
disabled={Boolean(editingAssetId)}
|
||||
onClick={() => onRequestAppend('sku')}
|
||||
className="aspect-square w-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -20,8 +20,9 @@
|
||||
- `20260827_add_ding_workflow_detail_fields`
|
||||
- `20260826_add_pack_video_early_access`
|
||||
- `20260828_add_pack_item_can_edit_assets`
|
||||
- `20260828_add_task_detail_change_approvals`
|
||||
|
||||
它们新增可空租约字段/索引、修改未来 Logo 默认值、精确把 `system_settings` 品牌两列中的退役 `s3.1080p.hk` 值改为本地品牌图,并把 `materials_uploadId_key` 重建为“`uploadId` 非空且素材未软删除”范围内的唯一 btree 索引;同时新增人员组织树、素材托管字段、素材不完整空壳标记、账号—人员绑定表,以及钉钉流程快照的需求类型、表单字段与索引,并在同一个事务中按钉钉唯一值做确定性回填。新增 `form_fields` 后会将同步游标重置一次,让下一轮同步完整补齐明细字段。索引迁移会先检测未删除素材中的重复 `uploadId`;人员绑定迁移会先检测一个钉钉身份对应多个账号的冲突。任一预检失败都会直接停止,不会猜测性合并、删除素材或留下一半回填。迁移没有自动 down;执行失败时停止发布,恢复依赖执行前的数据库备份。
|
||||
它们新增可空租约字段/索引、修改未来 Logo 默认值、精确把 `system_settings` 品牌两列中的退役 `s3.1080p.hk` 值改为本地品牌图,并把 `materials_uploadId_key` 重建为“`uploadId` 非空且素材未软删除”范围内的唯一 btree 索引;同时新增人员组织树、素材托管字段、素材不完整空壳标记、账号—人员绑定表、钉钉流程快照字段,以及任务详情图片变更审批单和通知 outbox。审批迁移只新增表、约束与索引,不改写现有素材和资产行。新增 `form_fields` 后会将同步游标重置一次,让下一轮同步完整补齐明细字段。索引迁移会先检测未删除素材中的重复 `uploadId`;人员绑定迁移会先检测一个钉钉身份对应多个账号的冲突。任一预检失败都会直接停止,不会猜测性合并、删除素材或留下一半回填。迁移没有自动 down;执行失败时停止发布,恢复依赖执行前的数据库备份。
|
||||
|
||||
## 强制前提
|
||||
|
||||
|
||||
@ -71,6 +71,45 @@ export async function register() {
|
||||
console.error('[Instrumentation] 启动作作品过稿通知任务失败:', error)
|
||||
}
|
||||
|
||||
try {
|
||||
const { ensureTaskDetailChangeNotificationWorkerStarted } = await import('./lib/task-detail-change-notifications')
|
||||
const {
|
||||
expireDueTaskDetailChanges,
|
||||
recoverMissingTaskDetailChangeRequestNotifications,
|
||||
recoverStaleApplyingTaskDetailChanges,
|
||||
} = await import('./lib/task-detail-change-approval')
|
||||
ensureTaskDetailChangeNotificationWorkerStarted()
|
||||
const approvalGlobal = globalThis as typeof globalThis & {
|
||||
__taskDetailChangeExpiryStarted?: boolean
|
||||
}
|
||||
if (!approvalGlobal.__taskDetailChangeExpiryStarted) {
|
||||
approvalGlobal.__taskDetailChangeExpiryStarted = true
|
||||
setInterval(() => {
|
||||
void expireDueTaskDetailChanges().catch((error) => {
|
||||
console.error('[Instrumentation] 图片变更审批超时扫描失败:', error)
|
||||
})
|
||||
void recoverMissingTaskDetailChangeRequestNotifications().catch((error) => {
|
||||
console.error('[Instrumentation] 图片变更审批缺失通知补偿失败:', error)
|
||||
})
|
||||
void recoverStaleApplyingTaskDetailChanges().catch((error) => {
|
||||
console.error('[Instrumentation] 图片变更审批应用中断恢复失败:', error)
|
||||
})
|
||||
}, 60_000).unref?.()
|
||||
}
|
||||
void expireDueTaskDetailChanges().catch((error) => {
|
||||
console.error('[Instrumentation] 图片变更审批首次超时扫描失败:', error)
|
||||
})
|
||||
void recoverMissingTaskDetailChangeRequestNotifications().catch((error) => {
|
||||
console.error('[Instrumentation] 图片变更审批首次缺失通知补偿失败:', error)
|
||||
})
|
||||
void recoverStaleApplyingTaskDetailChanges().catch((error) => {
|
||||
console.error('[Instrumentation] 图片变更审批首次应用中断恢复失败:', error)
|
||||
})
|
||||
console.log('[Instrumentation] 图片变更审批通知与超时否决任务已启动')
|
||||
} catch (error) {
|
||||
console.error('[Instrumentation] 启动图片变更审批任务失败:', error)
|
||||
}
|
||||
|
||||
// 组织架构/在职状态/离职联动每日同步:应用启动即调度(每日 02:00 Asia/Shanghai),
|
||||
// 不再依赖用户访问考核调研懒启动,容器重启后自动恢复。
|
||||
try {
|
||||
|
||||
@ -4,9 +4,9 @@ import { getServerSession } from 'next-auth'
|
||||
|
||||
import { POST } from '@/app/api/materials/[id]/assets/batch/route'
|
||||
import {
|
||||
TaskDetailAssetEditError,
|
||||
batchEditTaskDetailImages,
|
||||
} from '@/lib/task-detail-asset-edit'
|
||||
submitTaskDetailChange,
|
||||
TaskDetailChangeApprovalError,
|
||||
} from '@/lib/task-detail-change-approval'
|
||||
|
||||
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
|
||||
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
|
||||
@ -18,10 +18,19 @@ jest.mock('@/lib/task-detail-asset-edit', () => ({
|
||||
this.statusCode = statusCode
|
||||
}
|
||||
},
|
||||
batchEditTaskDetailImages: jest.fn(),
|
||||
}))
|
||||
jest.mock('@/lib/task-detail-change-approval', () => ({
|
||||
TaskDetailChangeApprovalError: class TaskDetailChangeApprovalError extends Error {
|
||||
statusCode: number
|
||||
constructor(message: string, statusCode = 400) {
|
||||
super(message)
|
||||
this.statusCode = statusCode
|
||||
}
|
||||
},
|
||||
submitTaskDetailChange: jest.fn(),
|
||||
}))
|
||||
|
||||
const mockedBatch = batchEditTaskDetailImages as jest.Mock
|
||||
const mockedSubmit = submitTaskDetailChange as jest.Mock
|
||||
|
||||
function requestWith(body: unknown) {
|
||||
return { json: async () => body } as any
|
||||
@ -39,14 +48,14 @@ describe('任务-详情批量单图编辑路由', () => {
|
||||
{ params: { id: 'm1' } },
|
||||
)
|
||||
expect(response.status).toBe(401)
|
||||
expect(mockedBatch).not.toHaveBeenCalled()
|
||||
expect(mockedSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('operations 为空数组返回 400', async () => {
|
||||
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'u1', role: 'ADMIN' } })
|
||||
const response = await POST(requestWith({ operations: [] }), { params: { id: 'm1' } })
|
||||
expect(response.status).toBe(400)
|
||||
expect(mockedBatch).not.toHaveBeenCalled()
|
||||
expect(mockedSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('不支持的操作类型返回 400', async () => {
|
||||
@ -56,7 +65,7 @@ describe('任务-详情批量单图编辑路由', () => {
|
||||
{ params: { id: 'm1' } },
|
||||
)
|
||||
expect(response.status).toBe(400)
|
||||
expect(mockedBatch).not.toHaveBeenCalled()
|
||||
expect(mockedSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('replace 缺少 uploadId 返回 400', async () => {
|
||||
@ -70,7 +79,7 @@ describe('任务-详情批量单图编辑路由', () => {
|
||||
|
||||
test('合法混合操作透传给批量引擎并返回结果', async () => {
|
||||
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'u1', role: 'ADMIN' } })
|
||||
mockedBatch.mockResolvedValue({ success: true, appliedCount: 3, entryCount: 7 })
|
||||
mockedSubmit.mockResolvedValue({ mode: 'applied', data: { success: true, appliedCount: 3, entryCount: 7 } })
|
||||
|
||||
const response = await POST(
|
||||
requestWith({
|
||||
@ -86,22 +95,21 @@ describe('任务-详情批量单图编辑路由', () => {
|
||||
expect(response.status).toBe(200)
|
||||
const payload = await response.json()
|
||||
expect(payload.data.appliedCount).toBe(3)
|
||||
expect(mockedBatch).toHaveBeenCalledWith({
|
||||
materialId: 'm1',
|
||||
operatorUserId: 'u1',
|
||||
operatorRole: 'ADMIN',
|
||||
operations: [
|
||||
expect(mockedSubmit).toHaveBeenCalledWith(
|
||||
'm1',
|
||||
{ id: 'u1', role: 'ADMIN', isSuperAdmin: false },
|
||||
[
|
||||
{ kind: 'delete', assetId: 'a1' },
|
||||
{ kind: 'rename', assetId: 'a2', newFileName: '新名字.jpg' },
|
||||
{ kind: 'replace', assetId: 'a3', uploadId: 'up-1' },
|
||||
],
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
test('业务错误透传状态码与文案', async () => {
|
||||
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'u1' } })
|
||||
mockedBatch.mockRejectedValue(
|
||||
new TaskDetailAssetEditError('批量操作只支持同一分组(主图/SKU/详情)内的图片', 400),
|
||||
mockedSubmit.mockRejectedValue(
|
||||
new TaskDetailChangeApprovalError('批量操作只支持同一分组(主图/SKU/详情)内的图片', 400),
|
||||
)
|
||||
|
||||
const response = await POST(
|
||||
@ -115,7 +123,7 @@ describe('任务-详情批量单图编辑路由', () => {
|
||||
|
||||
test('未知异常返回 500 且不泄漏内部信息', async () => {
|
||||
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'u1' } })
|
||||
mockedBatch.mockRejectedValue(new Error('internal detail'))
|
||||
mockedSubmit.mockRejectedValue(new Error('internal detail'))
|
||||
|
||||
const response = await POST(
|
||||
requestWith({ operations: [{ kind: 'delete', assetId: 'a1' }] }),
|
||||
|
||||
585
lib/__tests__/task-detail-asset-edit-batch.test.ts
Normal file
585
lib/__tests__/task-detail-asset-edit-batch.test.ts
Normal file
@ -0,0 +1,585 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { execFile } from 'child_process'
|
||||
import { writeFileSync } from 'fs'
|
||||
import { mkdtemp, rm, writeFile } from 'fs/promises'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import sharp from 'sharp'
|
||||
|
||||
jest.mock('child_process', () => ({ execFile: jest.fn() }))
|
||||
jest.mock('@/lib/material-authorization', () => ({ canManageMaterial: jest.fn() }))
|
||||
jest.mock('@/lib/material-log', () => ({ createMaterialLog: jest.fn().mockResolvedValue(undefined) }))
|
||||
jest.mock('@/lib/file-cleanup', () => ({ deleteFileIfUnreferenced: jest.fn().mockResolvedValue(undefined) }))
|
||||
jest.mock('@/lib/material-upload-cross-process-lock', () => ({ acquireTaskMaterialCompletionLock: jest.fn() }))
|
||||
jest.mock('@/lib/material-type-mapper', () => ({ isProductPackageType: jest.fn(() => true) }))
|
||||
jest.mock('@/lib/task-detail-full-set', () => ({ deriveTaskDetailIsFullSet: jest.fn(() => false) }))
|
||||
jest.mock('@/lib/external-auto-listing', () => ({
|
||||
queueTaskDetailBarcodeImagesUpdatedNotification: jest.fn(),
|
||||
queueTaskDetailFullSetNotification: jest.fn(),
|
||||
}))
|
||||
jest.mock('@/lib/public-display', () => ({
|
||||
buildTaskDetailPublicDisplayPath: jest.fn((materialId: string, group: string, fileName: string) => `${materialId}/${group}/${fileName}`),
|
||||
uploadPublicDisplayToS3: jest.fn(),
|
||||
}))
|
||||
jest.mock('@/lib/temp-cleanup', () => ({ cleanupConsumedUploadSession: jest.fn().mockResolvedValue(undefined) }))
|
||||
jest.mock('@/lib/remote-upload-sync', () => ({ deleteStoredUploadFileFromRemote: jest.fn().mockResolvedValue(undefined) }))
|
||||
jest.mock('@/lib/upload-sessions', () => ({
|
||||
getOwnedUploadSession: jest.fn(),
|
||||
getUploadedFile: jest.fn(),
|
||||
isSafeUploadId: jest.fn(() => true),
|
||||
}))
|
||||
jest.mock('@/lib/task-material-processor', () => ({
|
||||
createZipFromFiles: jest.fn(),
|
||||
createZipFromZips: jest.fn(),
|
||||
}))
|
||||
jest.mock('@/lib/zip-entry-reader', () => ({
|
||||
createTaskDetailEditWorkspace: jest.fn(),
|
||||
extractZipEntriesToDirectory: jest.fn(),
|
||||
listZipEntries: jest.fn(),
|
||||
}))
|
||||
jest.mock('@/lib/upload-paths', () => ({ resolveStoredUploadPath: jest.fn((value: string) => value) }))
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
material: {
|
||||
findFirst: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
materialAsset: {
|
||||
findMany: jest.fn(),
|
||||
count: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
deleteMany: jest.fn(),
|
||||
create: jest.fn(),
|
||||
createMany: jest.fn(),
|
||||
},
|
||||
materialAssetBarcode: {
|
||||
findMany: jest.fn(),
|
||||
createMany: jest.fn(),
|
||||
},
|
||||
taskDetailChangeRequest: {
|
||||
updateMany: jest.fn(),
|
||||
},
|
||||
$transaction: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { canManageMaterial } from '@/lib/material-authorization'
|
||||
import { queueTaskDetailBarcodeImagesUpdatedNotification } from '@/lib/external-auto-listing'
|
||||
import { acquireTaskMaterialCompletionLock } from '@/lib/material-upload-cross-process-lock'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { uploadPublicDisplayToS3 } from '@/lib/public-display'
|
||||
import { createZipFromFiles, createZipFromZips } from '@/lib/task-material-processor'
|
||||
import {
|
||||
ServerTrustedTaskDetailStagedFile,
|
||||
TaskDetailAssetEditError,
|
||||
batchEditTaskDetailImages,
|
||||
} from '@/lib/task-detail-asset-edit'
|
||||
import { cleanupConsumedUploadSession } from '@/lib/temp-cleanup'
|
||||
import { getOwnedUploadSession, getUploadedFile } from '@/lib/upload-sessions'
|
||||
import { createTaskDetailEditWorkspace, extractZipEntriesToDirectory, listZipEntries } from '@/lib/zip-entry-reader'
|
||||
|
||||
const mockExecFile = execFile as unknown as jest.Mock
|
||||
const mockCanManageMaterial = canManageMaterial as jest.Mock
|
||||
const mockAcquireLock = acquireTaskMaterialCompletionLock as jest.Mock
|
||||
const mockUploadPreview = uploadPublicDisplayToS3 as jest.Mock
|
||||
const mockCreateZipFromFiles = createZipFromFiles as jest.Mock
|
||||
const mockCreateZipFromZips = createZipFromZips as jest.Mock
|
||||
const mockCleanupConsumedUploadSession = cleanupConsumedUploadSession as jest.Mock
|
||||
const mockGetOwnedUploadSession = getOwnedUploadSession as jest.Mock
|
||||
const mockGetUploadedFile = getUploadedFile as jest.Mock
|
||||
const mockCreateWorkspace = createTaskDetailEditWorkspace as jest.Mock
|
||||
const mockExtractZipEntries = extractZipEntriesToDirectory as jest.Mock
|
||||
const mockListZipEntries = listZipEntries as jest.Mock
|
||||
const mockQueueSkuNotification = queueTaskDetailBarcodeImagesUpdatedNotification as jest.Mock
|
||||
|
||||
type PreviewRow = {
|
||||
id: string
|
||||
assetType: string
|
||||
fileUrl: string
|
||||
fileName: string
|
||||
sortOrder: number
|
||||
skuCode?: string | null
|
||||
createdAt?: Date | null
|
||||
}
|
||||
|
||||
describe('task-detail asset edit batch data-plane', () => {
|
||||
let tempRoot: string
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
tempRoot = await mkdtemp(path.join(os.tmpdir(), 'task-detail-batch-'))
|
||||
|
||||
mockCanManageMaterial.mockResolvedValue(true)
|
||||
mockAcquireLock.mockResolvedValue(Object.assign(jest.fn().mockResolvedValue(undefined), {
|
||||
assertHeld: jest.fn(),
|
||||
}))
|
||||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
|
||||
id: 'material-1',
|
||||
userId: 'operator-1',
|
||||
materialType: '任务-详情',
|
||||
taskStatus: 'completed',
|
||||
productModel: 'MODEL-1',
|
||||
authorName: '作者A',
|
||||
categoryName: '店铺A',
|
||||
previewUrl: 'https://preview/existing-cover.webp',
|
||||
isFullSet: false,
|
||||
updatedAt: new Date('2026-08-28T08:00:00.000Z'),
|
||||
})
|
||||
;(prisma.materialAsset.count as jest.Mock).mockResolvedValue(0)
|
||||
;(prisma.material.update as jest.Mock).mockResolvedValue(undefined)
|
||||
;(prisma.materialAsset.update as jest.Mock).mockResolvedValue(undefined)
|
||||
;(prisma.materialAsset.delete as jest.Mock).mockResolvedValue(undefined)
|
||||
;(prisma.materialAsset.deleteMany as jest.Mock).mockResolvedValue({ count: 0 })
|
||||
;(prisma.materialAsset.create as jest.Mock).mockResolvedValue(undefined)
|
||||
;(prisma.materialAsset.createMany as jest.Mock).mockResolvedValue({ count: 0 })
|
||||
;(prisma.materialAssetBarcode.findMany as jest.Mock).mockResolvedValue([])
|
||||
;(prisma.materialAssetBarcode.createMany as jest.Mock).mockResolvedValue({ count: 0 })
|
||||
;(prisma.taskDetailChangeRequest.updateMany as jest.Mock).mockResolvedValue({ count: 1 })
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback: any) => callback({
|
||||
material: prisma.material,
|
||||
materialAsset: prisma.materialAsset,
|
||||
materialAssetBarcode: prisma.materialAssetBarcode,
|
||||
taskDetailChangeRequest: prisma.taskDetailChangeRequest,
|
||||
}))
|
||||
mockUploadPreview.mockImplementation(async (_buffer: Buffer, key: string) => `https://preview/${key}`)
|
||||
mockCreateZipFromFiles.mockResolvedValue({
|
||||
filePath: 'uploads/materials/group.zip',
|
||||
fileName: 'group.zip',
|
||||
fileSize: 123,
|
||||
})
|
||||
mockCreateZipFromZips.mockResolvedValue({
|
||||
filePath: 'uploads/materials/all.zip',
|
||||
fileName: 'all.zip',
|
||||
fileSize: 456,
|
||||
})
|
||||
mockCreateWorkspace.mockImplementation(async () => {
|
||||
const dir = await mkdtemp(path.join(tempRoot, 'workspace-'))
|
||||
return {
|
||||
dir,
|
||||
cleanup: () => rm(dir, { recursive: true, force: true }),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function createSolidImage(fileName: string, width: number, height: number, format: 'png' | 'webp' = 'png') {
|
||||
const filePath = path.join(tempRoot, fileName)
|
||||
const buffer = await sharp({
|
||||
create: {
|
||||
width,
|
||||
height,
|
||||
channels: 3,
|
||||
background: { r: 180, g: 180, b: 180 },
|
||||
},
|
||||
})[format]().toBuffer()
|
||||
await writeFile(filePath, buffer)
|
||||
return { filePath, size: buffer.length }
|
||||
}
|
||||
|
||||
async function createAnimatedGif(fileName: string, width: number, height: number) {
|
||||
const redFrame = Buffer.alloc(width * height * 4)
|
||||
const blueFrame = Buffer.alloc(width * height * 4)
|
||||
for (let index = 0; index < width * height; index += 1) {
|
||||
redFrame[index * 4] = 255
|
||||
redFrame[index * 4 + 3] = 255
|
||||
blueFrame[index * 4 + 2] = 255
|
||||
blueFrame[index * 4 + 3] = 255
|
||||
}
|
||||
const buffer = await sharp(Buffer.concat([redFrame, blueFrame]), {
|
||||
raw: { width, height: height * 2, pageHeight: height, channels: 4 },
|
||||
}).gif({ loop: 0, delay: [120, 120] }).toBuffer()
|
||||
const filePath = path.join(tempRoot, fileName)
|
||||
await writeFile(filePath, buffer)
|
||||
return { filePath, size: buffer.length, buffer }
|
||||
}
|
||||
|
||||
function configureUploadSources(files: Record<string, { path: string; size: number; fileName: string }>) {
|
||||
mockGetOwnedUploadSession.mockImplementation(async (uploadId: string) => {
|
||||
const item = files[uploadId]
|
||||
return item ? { fileName: item.fileName, fileSize: item.size } : null
|
||||
})
|
||||
mockGetUploadedFile.mockImplementation(async (uploadId: string) => files[uploadId]?.path || null)
|
||||
}
|
||||
|
||||
function configureAssetQueries(options: {
|
||||
previewRows: PreviewRow[]
|
||||
zipRows: Array<{ id: string; assetType: string; fileUrl: string; fileName: string | null }>
|
||||
extraFindMany?: unknown[]
|
||||
}) {
|
||||
const mock = prisma.materialAsset.findMany as jest.Mock
|
||||
mock.mockReset()
|
||||
mock.mockResolvedValueOnce(options.previewRows)
|
||||
mock.mockResolvedValueOnce(options.zipRows)
|
||||
for (const item of options.extraFindMany || []) {
|
||||
mock.mockResolvedValueOnce(item)
|
||||
}
|
||||
}
|
||||
|
||||
test('sku 追加支持多张按顺序提交,并保留 skuCode/barcodes', async () => {
|
||||
const existingSource = await createSolidImage('sku-old.png', 80, 80)
|
||||
const existingPreview = await createSolidImage('sku-old-preview.webp', 80, 80, 'webp')
|
||||
const append1 = await createSolidImage('append-1.png', 90, 90)
|
||||
const append2 = await createSolidImage('append-2.png', 100, 100)
|
||||
|
||||
configureUploadSources({
|
||||
'up-1': { path: append1.filePath, size: append1.size, fileName: 'append-1.png' },
|
||||
'up-2': { path: append2.filePath, size: append2.size, fileName: 'append-2.png' },
|
||||
})
|
||||
configureAssetQueries({
|
||||
previewRows: [
|
||||
{ id: 'sku-0', assetType: 'sku-preview', fileUrl: existingPreview.filePath, fileName: 'sku-old-preview.webp', sortOrder: 0, skuCode: 'OLD' },
|
||||
],
|
||||
zipRows: [
|
||||
{ id: 'zip-sku', assetType: 'sku', fileUrl: path.join(tempRoot, 'sku.zip'), fileName: 'sku.zip' },
|
||||
{ id: 'zip-all', assetType: 'all', fileUrl: path.join(tempRoot, 'all.zip'), fileName: 'all.zip' },
|
||||
],
|
||||
extraFindMany: [
|
||||
[
|
||||
{ id: 'new-sku-1', fileUrl: 'https://preview/material-1/sku/append-1__srv-1.webp' },
|
||||
{ id: 'new-sku-2', fileUrl: 'https://preview/material-1/sku/append-2__srv-2.webp' },
|
||||
],
|
||||
[
|
||||
{ id: 'new-sku-1' },
|
||||
{ id: 'new-sku-2' },
|
||||
],
|
||||
],
|
||||
})
|
||||
mockListZipEntries.mockResolvedValue([{ name: 'sku-old.png' }])
|
||||
mockExtractZipEntries.mockResolvedValue([
|
||||
{ entryName: 'sku-old.png', filePath: existingSource.filePath, size: existingSource.size },
|
||||
])
|
||||
|
||||
const result = await batchEditTaskDetailImages({
|
||||
materialId: 'material-1',
|
||||
operatorUserId: 'operator-1',
|
||||
approvalRequestId: 'request-atomic',
|
||||
operations: [
|
||||
{ kind: 'append', group: 'sku', uploadId: 'up-1', skuCode: 'SKU-1', barcodes: ['b-1'] },
|
||||
{ kind: 'append', group: 'sku', uploadId: 'up-2', skuCode: 'SKU-2', barcodes: ['b-2', 'b-3'] },
|
||||
],
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ success: true, appliedCount: 2, entryCount: 3 })
|
||||
expect(mockCreateZipFromFiles).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'sku-old.png' }),
|
||||
expect.objectContaining({ name: 'append-1.png' }),
|
||||
expect.objectContaining({ name: 'append-2.png' }),
|
||||
]),
|
||||
'material-1',
|
||||
'MODEL-1',
|
||||
'任务-详情',
|
||||
'SKU图',
|
||||
'作者A',
|
||||
'店铺A',
|
||||
)
|
||||
expect((prisma.materialAsset.createMany as jest.Mock).mock.calls[0][0].data).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ skuCode: 'SKU-1' }),
|
||||
expect.objectContaining({ skuCode: 'SKU-2' }),
|
||||
]),
|
||||
)
|
||||
expect(mockCleanupConsumedUploadSession).toHaveBeenCalledWith('up-1')
|
||||
expect(mockCleanupConsumedUploadSession).toHaveBeenCalledWith('up-2')
|
||||
expect(mockQueueSkuNotification).toHaveBeenCalledWith('material-1', ['new-sku-1', 'new-sku-2'])
|
||||
expect(prisma.taskDetailChangeRequest.updateMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: 'request-atomic',
|
||||
materialId: 'material-1',
|
||||
status: 'applying',
|
||||
},
|
||||
data: expect.objectContaining({ status: 'applied', applyError: null }),
|
||||
})
|
||||
})
|
||||
|
||||
test('详情插图要求 seam 两侧 preview 必须相邻', async () => {
|
||||
const sourceA = await createSolidImage('detail-a.png', 80, 120)
|
||||
const sourceB = await createSolidImage('detail-b.png', 80, 120)
|
||||
const sourceC = await createSolidImage('detail-c.png', 80, 120)
|
||||
const insertFile = await createSolidImage('insert-middle.png', 70, 70)
|
||||
|
||||
configureUploadSources({
|
||||
insert: { path: insertFile.filePath, size: insertFile.size, fileName: 'insert-middle.png' },
|
||||
})
|
||||
configureAssetQueries({
|
||||
previewRows: [
|
||||
{ id: 'a', assetType: 'detail-preview', fileUrl: sourceA.filePath, fileName: '1_01-preview.webp', sortOrder: 0 },
|
||||
{ id: 'b', assetType: 'detail-preview', fileUrl: sourceB.filePath, fileName: '1_02-preview.webp', sortOrder: 1 },
|
||||
{ id: 'c', assetType: 'detail-preview', fileUrl: sourceC.filePath, fileName: '1_03-preview.webp', sortOrder: 2 },
|
||||
],
|
||||
zipRows: [
|
||||
{ id: 'zip-detail', assetType: 'detail', fileUrl: path.join(tempRoot, 'detail.zip'), fileName: 'detail.zip' },
|
||||
],
|
||||
})
|
||||
mockListZipEntries.mockResolvedValue([{ name: '1_01.jpg' }, { name: '1_02.jpg' }, { name: '1_03.jpg' }])
|
||||
mockExtractZipEntries.mockResolvedValue([
|
||||
{ entryName: '1_01.jpg', filePath: sourceA.filePath, size: sourceA.size },
|
||||
{ entryName: '1_02.jpg', filePath: sourceB.filePath, size: sourceB.size },
|
||||
{ entryName: '1_03.jpg', filePath: sourceC.filePath, size: sourceC.size },
|
||||
])
|
||||
|
||||
await expect(batchEditTaskDetailImages({
|
||||
materialId: 'material-1',
|
||||
operatorUserId: 'operator-1',
|
||||
operations: [
|
||||
{ kind: 'insert', afterAssetId: 'a', beforeAssetId: 'c', uploadId: 'insert' },
|
||||
],
|
||||
})).rejects.toThrow('相邻预览之间')
|
||||
expect(mockCreateZipFromFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('详情跨 run 插图支持 server-trusted staged file,不依赖 upload session', async () => {
|
||||
const sourceA = await createSolidImage('cross-a.png', 80, 120)
|
||||
const sourceB = await createSolidImage('cross-b.png', 80, 120)
|
||||
const staged = await createSolidImage('cross-insert.png', 70, 90)
|
||||
|
||||
configureAssetQueries({
|
||||
previewRows: [
|
||||
{ id: 'a', assetType: 'detail-preview', fileUrl: sourceA.filePath, fileName: '1_01-preview.webp', sortOrder: 0 },
|
||||
{ id: 'b', assetType: 'detail-preview', fileUrl: sourceB.filePath, fileName: '1_02-preview.webp', sortOrder: 1 },
|
||||
],
|
||||
zipRows: [
|
||||
{ id: 'zip-detail', assetType: 'detail', fileUrl: path.join(tempRoot, 'detail.zip'), fileName: 'detail.zip' },
|
||||
],
|
||||
})
|
||||
mockListZipEntries.mockResolvedValue([{ name: '1_01.jpg' }, { name: '1_02.jpg' }])
|
||||
mockExtractZipEntries.mockResolvedValue([
|
||||
{ entryName: '1_01.jpg', filePath: sourceA.filePath, size: sourceA.size },
|
||||
{ entryName: '1_02.jpg', filePath: sourceB.filePath, size: sourceB.size },
|
||||
])
|
||||
|
||||
const stagedFile: ServerTrustedTaskDetailStagedFile = {
|
||||
trusted: true,
|
||||
filePath: staged.filePath,
|
||||
fileName: 'cross-insert.png',
|
||||
fileSize: staged.size,
|
||||
}
|
||||
|
||||
const result = await batchEditTaskDetailImages({
|
||||
materialId: 'material-1',
|
||||
operatorUserId: 'operator-1',
|
||||
operations: [
|
||||
{ kind: 'insert', afterAssetId: 'a', beforeAssetId: 'b', stagedFile },
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.entryCount).toBe(3)
|
||||
expect(mockGetOwnedUploadSession).not.toHaveBeenCalled()
|
||||
expect(mockCreateZipFromFiles.mock.calls[0][0].map((item: { name: string }) => item.name)).toEqual([
|
||||
'1_01.jpg',
|
||||
'cross-insert.png',
|
||||
'1_02.jpg',
|
||||
])
|
||||
})
|
||||
|
||||
test('详情同 run 静态长图插图会拆成上半/插入/下半并重建 preview', async () => {
|
||||
const longSource = await createSolidImage('long-source.png', 1000, 4000)
|
||||
const preview1 = await createSolidImage('long-001.webp', 1000, 1500, 'webp')
|
||||
const preview2 = await createSolidImage('long-002.webp', 1000, 1500, 'webp')
|
||||
const preview3 = await createSolidImage('long-003.webp', 1000, 1000, 'webp')
|
||||
const inserted = await createSolidImage('insert-static.png', 900, 600)
|
||||
|
||||
configureUploadSources({
|
||||
insert: { path: inserted.filePath, size: inserted.size, fileName: 'insert-static.png' },
|
||||
})
|
||||
configureAssetQueries({
|
||||
previewRows: [
|
||||
{ id: 'slice-1', assetType: 'detail-preview', fileUrl: preview1.filePath, fileName: 'long-001.webp', sortOrder: 0 },
|
||||
{ id: 'slice-2', assetType: 'detail-preview', fileUrl: preview2.filePath, fileName: 'long-002.webp', sortOrder: 1 },
|
||||
{ id: 'slice-3', assetType: 'detail-preview', fileUrl: preview3.filePath, fileName: 'long-003.webp', sortOrder: 2 },
|
||||
],
|
||||
zipRows: [
|
||||
{ id: 'zip-detail', assetType: 'detail', fileUrl: path.join(tempRoot, 'detail.zip'), fileName: 'detail.zip' },
|
||||
],
|
||||
})
|
||||
mockListZipEntries.mockResolvedValue([{ name: 'long.png' }])
|
||||
mockExtractZipEntries.mockResolvedValue([
|
||||
{ entryName: 'long.png', filePath: longSource.filePath, size: longSource.size },
|
||||
])
|
||||
|
||||
const result = await batchEditTaskDetailImages({
|
||||
materialId: 'material-1',
|
||||
operatorUserId: 'operator-1',
|
||||
operations: [
|
||||
{ kind: 'insert', afterAssetId: 'slice-1', beforeAssetId: 'slice-2', uploadId: 'insert' },
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.entryCount).toBe(3)
|
||||
expect(mockCreateZipFromFiles.mock.calls[0][0].map((item: { name: string }) => item.name)).toEqual([
|
||||
'long__top.png',
|
||||
'insert-static.png',
|
||||
'long__bottom.png',
|
||||
])
|
||||
expect((prisma.materialAsset.createMany as jest.Mock).mock.calls[0][0].data).toHaveLength(4)
|
||||
})
|
||||
|
||||
test('同一长图接缝一次选择多张图片时按选择顺序连续插入', async () => {
|
||||
const longSource = await createSolidImage('multi-long-source.png', 1000, 3000)
|
||||
const preview1 = await createSolidImage('multi-long-001.webp', 1000, 1500, 'webp')
|
||||
const preview2 = await createSolidImage('multi-long-002.webp', 1000, 1500, 'webp')
|
||||
const inserted1 = await createSolidImage('insert-first.png', 900, 500)
|
||||
const inserted2 = await createSolidImage('insert-second.png', 900, 600)
|
||||
|
||||
configureUploadSources({
|
||||
'insert-1': { path: inserted1.filePath, size: inserted1.size, fileName: 'insert-first.png' },
|
||||
'insert-2': { path: inserted2.filePath, size: inserted2.size, fileName: 'insert-second.png' },
|
||||
})
|
||||
configureAssetQueries({
|
||||
previewRows: [
|
||||
{ id: 'multi-slice-1', assetType: 'detail-preview', fileUrl: preview1.filePath, fileName: 'multi-long-001.webp', sortOrder: 0 },
|
||||
{ id: 'multi-slice-2', assetType: 'detail-preview', fileUrl: preview2.filePath, fileName: 'multi-long-002.webp', sortOrder: 1 },
|
||||
],
|
||||
zipRows: [
|
||||
{ id: 'zip-detail', assetType: 'detail', fileUrl: path.join(tempRoot, 'multi-detail.zip'), fileName: 'multi-detail.zip' },
|
||||
],
|
||||
})
|
||||
mockListZipEntries.mockResolvedValue([{ name: 'multi-long.png' }])
|
||||
mockExtractZipEntries.mockResolvedValue([
|
||||
{ entryName: 'multi-long.png', filePath: longSource.filePath, size: longSource.size },
|
||||
])
|
||||
|
||||
const result = await batchEditTaskDetailImages({
|
||||
materialId: 'material-1',
|
||||
operatorUserId: 'operator-1',
|
||||
operations: [
|
||||
{ kind: 'insert', afterAssetId: 'multi-slice-1', beforeAssetId: 'multi-slice-2', uploadId: 'insert-1' },
|
||||
{ kind: 'insert', afterAssetId: 'multi-slice-1', beforeAssetId: 'multi-slice-2', uploadId: 'insert-2' },
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.entryCount).toBe(4)
|
||||
expect(mockCreateZipFromFiles.mock.calls[0][0].map((item: { name: string }) => item.name)).toEqual([
|
||||
'multi-long__top.png',
|
||||
'insert-first.png',
|
||||
'insert-second.png',
|
||||
'multi-long__bottom.png',
|
||||
])
|
||||
})
|
||||
|
||||
test('详情同 run GIF 长图插图走 ffmpeg 保留动画链路', async () => {
|
||||
const longGif = await createAnimatedGif('long-run.gif', 100, 2000)
|
||||
const topGif = await createAnimatedGif('long-run-top.gif', 100, 1500)
|
||||
const bottomGif = await createAnimatedGif('long-run-bottom.gif', 100, 500)
|
||||
const preview1 = await createSolidImage('gif-001.webp', 100, 1500, 'webp')
|
||||
const preview2 = await createSolidImage('gif-002.webp', 100, 500, 'webp')
|
||||
const inserted = await createSolidImage('insert-gif-static.png', 100, 300)
|
||||
|
||||
configureUploadSources({
|
||||
insert: { path: inserted.filePath, size: inserted.size, fileName: 'insert-gif-static.png' },
|
||||
})
|
||||
configureAssetQueries({
|
||||
previewRows: [
|
||||
{ id: 'gif-1', assetType: 'detail-preview', fileUrl: preview1.filePath, fileName: 'gif-run-001.webp', sortOrder: 0 },
|
||||
{ id: 'gif-2', assetType: 'detail-preview', fileUrl: preview2.filePath, fileName: 'gif-run-002.webp', sortOrder: 1 },
|
||||
],
|
||||
zipRows: [
|
||||
{ id: 'zip-detail', assetType: 'detail', fileUrl: path.join(tempRoot, 'detail-gif.zip'), fileName: 'detail-gif.zip' },
|
||||
],
|
||||
})
|
||||
mockListZipEntries.mockResolvedValue([{ name: 'gif-run.gif' }])
|
||||
mockExtractZipEntries.mockResolvedValue([
|
||||
{ entryName: 'gif-run.gif', filePath: longGif.filePath, size: longGif.size },
|
||||
])
|
||||
|
||||
mockExecFile.mockImplementation((file: string, args: string[], options: any, callback: (error: Error | null, stdout?: string, stderr?: string) => void) => {
|
||||
const outputPath = args[args.length - 1]
|
||||
if (String(outputPath).endsWith('.gif')) {
|
||||
const filters = args.join(' ')
|
||||
writeFileSync(outputPath, filters.includes('crop=100:1500') ? topGif.buffer : bottomGif.buffer)
|
||||
} else {
|
||||
writeFileSync(outputPath, Buffer.from('RIFFxxxxWEBP'))
|
||||
}
|
||||
callback(null, '', '')
|
||||
})
|
||||
|
||||
const result = await batchEditTaskDetailImages({
|
||||
materialId: 'material-1',
|
||||
operatorUserId: 'operator-1',
|
||||
operations: [
|
||||
{ kind: 'insert', afterAssetId: 'gif-1', beforeAssetId: 'gif-2', uploadId: 'insert' },
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.entryCount).toBe(3)
|
||||
expect(mockExecFile.mock.calls.length).toBeGreaterThanOrEqual(4)
|
||||
expect(mockExecFile.mock.calls.some((call) => String(call[1]).includes('libwebp_anim'))).toBe(true)
|
||||
expect(mockExecFile.mock.calls.some((call) => String(call[1]).includes('crop='))).toBe(true)
|
||||
expect(mockCreateZipFromFiles.mock.calls[0][0].map((item: { name: string }) => item.name)).toEqual([
|
||||
'gif-run__top.gif',
|
||||
'insert-gif-static.png',
|
||||
'gif-run__bottom.gif',
|
||||
])
|
||||
})
|
||||
|
||||
test('追加后超过图片组上限会拒绝提交', async () => {
|
||||
const existingRows: PreviewRow[] = []
|
||||
const entryNames: Array<{ name: string; filePath: string }> = []
|
||||
for (let index = 0; index < 300; index += 1) {
|
||||
existingRows.push({
|
||||
id: `main-${index}`,
|
||||
assetType: 'main-preview',
|
||||
fileUrl: `https://preview/existing-${index}.webp`,
|
||||
fileName: `existing-${index}-preview.webp`,
|
||||
sortOrder: index,
|
||||
})
|
||||
entryNames.push({ name: `existing-${index}.jpg`, filePath: path.join(tempRoot, `existing-${index}.jpg`) })
|
||||
}
|
||||
const appended = await createSolidImage('append-over-limit.png', 80, 80)
|
||||
|
||||
configureUploadSources({
|
||||
append: { path: appended.filePath, size: appended.size, fileName: 'append-over-limit.png' },
|
||||
})
|
||||
configureAssetQueries({
|
||||
previewRows: existingRows,
|
||||
zipRows: [
|
||||
{ id: 'zip-main', assetType: 'main', fileUrl: path.join(tempRoot, 'main.zip'), fileName: 'main.zip' },
|
||||
],
|
||||
})
|
||||
mockListZipEntries.mockResolvedValue(entryNames.map((item) => ({ name: item.name })))
|
||||
mockExtractZipEntries.mockResolvedValue(entryNames.map((item, index) => ({
|
||||
entryName: item.name,
|
||||
filePath: item.filePath,
|
||||
size: index + 1,
|
||||
})))
|
||||
|
||||
await expect(batchEditTaskDetailImages({
|
||||
materialId: 'material-1',
|
||||
operatorUserId: 'operator-1',
|
||||
operations: [
|
||||
{ kind: 'append', group: 'main', uploadId: 'append' },
|
||||
],
|
||||
})).rejects.toThrow('最多保留 300 张')
|
||||
expect(mockCreateZipFromFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('审批期间正式素材已更新时在持锁上下文中停止应用', async () => {
|
||||
;(prisma.material.findFirst as jest.Mock).mockResolvedValueOnce({
|
||||
id: 'material-1',
|
||||
userId: 'operator-1',
|
||||
materialType: '任务-详情',
|
||||
taskStatus: 'completed',
|
||||
productModel: 'MODEL-1',
|
||||
authorName: '作者A',
|
||||
categoryName: '店铺A',
|
||||
previewUrl: 'https://preview/existing-cover.webp',
|
||||
isFullSet: false,
|
||||
updatedAt: new Date('2026-08-28T08:01:00.000Z'),
|
||||
})
|
||||
|
||||
await expect(batchEditTaskDetailImages({
|
||||
materialId: 'material-1',
|
||||
operatorUserId: 'operator-1',
|
||||
expectedMaterialUpdatedAt: new Date('2026-08-28T08:00:00.000Z'),
|
||||
operations: [{ kind: 'append', group: 'main', uploadId: 'append' }],
|
||||
})).rejects.toThrow('正式素材在审批期间已更新')
|
||||
|
||||
expect(prisma.materialAsset.findMany).not.toHaveBeenCalled()
|
||||
expect(mockCreateZipFromFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -7,11 +7,7 @@ import {
|
||||
PATCH,
|
||||
POST,
|
||||
} from '@/app/api/materials/[id]/assets/[assetId]/route'
|
||||
import {
|
||||
deleteTaskDetailImage,
|
||||
renameTaskDetailImage,
|
||||
replaceTaskDetailImage,
|
||||
} from '@/lib/task-detail-asset-edit'
|
||||
import { submitTaskDetailChange } from '@/lib/task-detail-change-approval'
|
||||
|
||||
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
|
||||
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
|
||||
@ -23,14 +19,19 @@ jest.mock('@/lib/task-detail-asset-edit', () => ({
|
||||
this.statusCode = statusCode
|
||||
}
|
||||
},
|
||||
deleteTaskDetailImage: jest.fn(),
|
||||
renameTaskDetailImage: jest.fn(),
|
||||
replaceTaskDetailImage: jest.fn(),
|
||||
}))
|
||||
jest.mock('@/lib/task-detail-change-approval', () => ({
|
||||
TaskDetailChangeApprovalError: class TaskDetailChangeApprovalError extends Error {
|
||||
statusCode: number
|
||||
constructor(message: string, statusCode = 400) {
|
||||
super(message)
|
||||
this.statusCode = statusCode
|
||||
}
|
||||
},
|
||||
submitTaskDetailChange: jest.fn(),
|
||||
}))
|
||||
|
||||
const mockedDelete = deleteTaskDetailImage as jest.Mock
|
||||
const mockedRename = renameTaskDetailImage as jest.Mock
|
||||
const mockedReplace = replaceTaskDetailImage as jest.Mock
|
||||
const mockedSubmit = submitTaskDetailChange as jest.Mock
|
||||
|
||||
describe('任务-详情单图编辑路由', () => {
|
||||
beforeEach(() => {
|
||||
@ -47,68 +48,59 @@ describe('任务-详情单图编辑路由', () => {
|
||||
]) {
|
||||
expect(response.status).toBe(401)
|
||||
}
|
||||
expect(mockedRename).not.toHaveBeenCalled()
|
||||
expect(mockedReplace).not.toHaveBeenCalled()
|
||||
expect(mockedDelete).not.toHaveBeenCalled()
|
||||
expect(mockedSubmit).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()
|
||||
expect(mockedSubmit).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()
|
||||
expect(mockedSubmit).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 })
|
||||
mockedSubmit.mockResolvedValue({ mode: 'applied', data: { 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: '新名字',
|
||||
})
|
||||
expect(mockedSubmit).toHaveBeenCalledWith(
|
||||
'm1',
|
||||
{ id: 'u1', role: 'USER', isSuperAdmin: false },
|
||||
[{ kind: 'rename', assetId: 'a1', 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',
|
||||
})
|
||||
expect(mockedSubmit).toHaveBeenCalledWith(
|
||||
'm1',
|
||||
{ id: 'u1', role: 'USER', isSuperAdmin: false },
|
||||
[{ kind: 'replace', assetId: 'a2', uploadId: 'upload-1' }],
|
||||
)
|
||||
|
||||
await DELETE({} as any, { params: { id: 'm1', assetId: 'a3' } })
|
||||
expect(mockedDelete).toHaveBeenCalledWith({
|
||||
materialId: 'm1',
|
||||
assetId: 'a3',
|
||||
operatorUserId: 'u1',
|
||||
operatorRole: 'USER',
|
||||
})
|
||||
expect(mockedSubmit).toHaveBeenCalledWith(
|
||||
'm1',
|
||||
{ id: 'u1', role: 'USER', isSuperAdmin: false },
|
||||
[{ kind: 'delete', assetId: 'a3' }],
|
||||
)
|
||||
})
|
||||
|
||||
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 { TaskDetailChangeApprovalError: MockedError } = await import('@/lib/task-detail-change-approval')
|
||||
mockedSubmit.mockRejectedValue(new (MockedError as any)('您没有权限编辑此素材', 403))
|
||||
|
||||
const response = await DELETE({} as any, { params: { id: 'm1', assetId: 'a1' } })
|
||||
expect(response.status).toBe(403)
|
||||
|
||||
386
lib/__tests__/task-detail-change-approval.test.ts
Normal file
386
lib/__tests__/task-detail-change-approval.test.ts
Normal file
@ -0,0 +1,386 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getEmployeeByUserId } from '@/lib/employee-directory'
|
||||
import { canManageMaterial } from '@/lib/material-authorization'
|
||||
import { batchEditTaskDetailImages } from '@/lib/task-detail-asset-edit'
|
||||
import {
|
||||
cleanupTaskDetailChangeStaging,
|
||||
resolveTaskDetailStagedFile,
|
||||
withTaskDetailStagedUploads,
|
||||
} from '@/lib/task-detail-change-staging'
|
||||
import { queueTaskDetailChangeNotification } from '@/lib/task-detail-change-notifications'
|
||||
import {
|
||||
decideTaskDetailChange,
|
||||
expireDueTaskDetailChanges,
|
||||
recoverMissingTaskDetailChangeRequestNotifications,
|
||||
recoverStaleApplyingTaskDetailChanges,
|
||||
submitTaskDetailChange,
|
||||
} from '@/lib/task-detail-change-approval'
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
material: { findFirst: jest.fn(), findUnique: jest.fn() },
|
||||
performanceSurveyEmployeeProfile: { findUnique: jest.fn() },
|
||||
taskDetailChangeRequest: {
|
||||
create: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
update: jest.fn(),
|
||||
updateMany: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/employee-directory', () => ({
|
||||
BINDING_STATUS: { ACTIVE: 'ACTIVE' },
|
||||
getEmployeeByUserId: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/material-authorization', () => ({ canManageMaterial: jest.fn() }))
|
||||
jest.mock('@/lib/user-permission-inherit', () => ({ canUserEditTaskDetailAssets: jest.fn() }))
|
||||
jest.mock('@/lib/department-permission', () => ({
|
||||
isSystemAdmin: jest.fn((role: string) => role === 'ADMIN' || role === 'SYSTEM_ADMIN'),
|
||||
}))
|
||||
jest.mock('@/lib/material-type-mapper', () => ({ isProductPackageType: jest.fn(() => true) }))
|
||||
jest.mock('@/lib/public-app-url', () => ({ getPublicAppOrigin: () => 'https://sc.example.com' }))
|
||||
jest.mock('@/lib/safe-redirect', () => ({ buildDingtalkEntryPath: (path: string) => path }))
|
||||
jest.mock('@/lib/task-detail-asset-edit', () => ({ batchEditTaskDetailImages: jest.fn() }))
|
||||
jest.mock('@/lib/task-detail-change-notifications', () => ({
|
||||
queueTaskDetailChangeNotification: jest.fn(),
|
||||
}))
|
||||
jest.mock('@/lib/task-detail-change-staging', () => ({
|
||||
cleanupTaskDetailChangeStaging: jest.fn(),
|
||||
resolveTaskDetailStagedFile: jest.fn(),
|
||||
withTaskDetailStagedUploads: jest.fn(),
|
||||
}))
|
||||
|
||||
const material = {
|
||||
id: 'material-1',
|
||||
userId: 'owner-1',
|
||||
materialType: '任务-详情',
|
||||
taskStatus: 'completed',
|
||||
taskNo: 'TASK-001',
|
||||
productModel: 'SPU-001',
|
||||
updatedAt: new Date('2026-08-28T00:00:00.000Z'),
|
||||
assets: [
|
||||
{ id: 'asset-1', assetType: 'main-preview' },
|
||||
{ id: 'detail-1', assetType: 'detail-preview' },
|
||||
],
|
||||
}
|
||||
|
||||
function employee(levelType: 'M' | 'P', dingtalkUserId = 'employee-ding') {
|
||||
return {
|
||||
employeeProfileId: `employee-${levelType}`,
|
||||
userId: 'user-1',
|
||||
displayName: levelType === 'M' ? '管理者' : '申请人',
|
||||
realName: null,
|
||||
flowerName: null,
|
||||
jobNumber: 'JS001',
|
||||
dingtalkUserId,
|
||||
departmentName: '品牌中心',
|
||||
departmentPath: '公司/品牌中心',
|
||||
positionTitle: '设计师',
|
||||
positionLevel: `${levelType}1`,
|
||||
levelType,
|
||||
isLeadership: levelType === 'M',
|
||||
managerName: '直属主管',
|
||||
managerDingtalkUserId: 'manager-ding',
|
||||
employmentStatus: 'active',
|
||||
}
|
||||
}
|
||||
|
||||
describe('任务详情图片变更主管审批', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(material)
|
||||
;(canManageMaterial as jest.Mock).mockResolvedValue(true)
|
||||
;(batchEditTaskDetailImages as jest.Mock).mockResolvedValue({ success: true })
|
||||
;(queueTaskDetailChangeNotification as jest.Mock).mockResolvedValue('notification-1')
|
||||
;(cleanupTaskDetailChangeStaging as jest.Mock).mockResolvedValue(undefined)
|
||||
;(resolveTaskDetailStagedFile as jest.Mock).mockReturnValue({
|
||||
trusted: true,
|
||||
filePath: '/uploads/staged/new.png',
|
||||
fileName: 'new.png',
|
||||
fileSize: 10,
|
||||
})
|
||||
})
|
||||
|
||||
test('M 级人员有权限时直接应用,不创建审批单', async () => {
|
||||
;(getEmployeeByUserId as jest.Mock).mockResolvedValue(employee('M'))
|
||||
|
||||
const result = await submitTaskDetailChange('material-1', {
|
||||
id: 'user-1',
|
||||
role: 'USER',
|
||||
}, [{ kind: 'delete', assetId: 'asset-1' }])
|
||||
|
||||
expect(result.mode).toBe('applied')
|
||||
expect(batchEditTaskDetailImages).toHaveBeenCalledWith(expect.objectContaining({
|
||||
materialId: 'material-1',
|
||||
operations: [{ kind: 'delete', assetId: 'asset-1' }],
|
||||
}))
|
||||
expect(prisma.taskDetailChangeRequest.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('P 级人员把替换文件转为持久暂存并创建 24 小时审批单', async () => {
|
||||
;(getEmployeeByUserId as jest.Mock).mockResolvedValue(employee('P'))
|
||||
;(prisma.performanceSurveyEmployeeProfile.findUnique as jest.Mock).mockResolvedValue({
|
||||
id: 'manager-employee',
|
||||
displayName: '直属主管',
|
||||
realName: null,
|
||||
employmentState: 'ACTIVE',
|
||||
dingtalkUserId: 'manager-ding',
|
||||
jobNumber: 'M001',
|
||||
departmentPath: '公司/品牌中心',
|
||||
positionLevel: 'M1',
|
||||
userBindings: [{ userId: 'manager-user' }],
|
||||
})
|
||||
;(withTaskDetailStagedUploads as jest.Mock).mockImplementation(async ({ commit }) => commit(new Map([
|
||||
['upload-1', { relativePath: 'task-detail-change-requests/request/new.png', fileName: 'new.png', fileSize: 10 }],
|
||||
])))
|
||||
;(prisma.taskDetailChangeRequest.create as jest.Mock).mockImplementation(async ({ data }) => ({
|
||||
...data,
|
||||
material,
|
||||
submittedAt: new Date(),
|
||||
}))
|
||||
|
||||
const result = await submitTaskDetailChange('material-1', {
|
||||
id: 'user-1',
|
||||
role: 'USER',
|
||||
}, [{ kind: 'replace', assetId: 'asset-1', uploadId: 'upload-1' }])
|
||||
|
||||
expect(result.mode).toBe('pending_approval')
|
||||
expect(batchEditTaskDetailImages).not.toHaveBeenCalled()
|
||||
expect(withTaskDetailStagedUploads).toHaveBeenCalledWith(expect.objectContaining({
|
||||
ownerUserId: 'user-1',
|
||||
uploadIds: ['upload-1'],
|
||||
}))
|
||||
expect(prisma.taskDetailChangeRequest.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
status: 'pending_manager_approval',
|
||||
managerDingtalkUserId: 'manager-ding',
|
||||
operations: [expect.objectContaining({
|
||||
kind: 'replace',
|
||||
assetId: 'asset-1',
|
||||
stagedFile: expect.objectContaining({ fileName: 'new.png' }),
|
||||
})],
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
test('P 级负责人仍需直属主管审批,不能因 isLeadership 绕过', async () => {
|
||||
;(getEmployeeByUserId as jest.Mock).mockResolvedValue({
|
||||
...employee('P'),
|
||||
isLeadership: true,
|
||||
})
|
||||
;(prisma.performanceSurveyEmployeeProfile.findUnique as jest.Mock).mockResolvedValue({
|
||||
id: 'manager-employee',
|
||||
displayName: '直属主管',
|
||||
realName: null,
|
||||
employmentState: 'ACTIVE',
|
||||
dingtalkUserId: 'manager-ding',
|
||||
jobNumber: 'M001',
|
||||
departmentPath: '公司/品牌中心',
|
||||
positionLevel: 'M1',
|
||||
userBindings: [{ userId: 'manager-user' }],
|
||||
})
|
||||
;(withTaskDetailStagedUploads as jest.Mock).mockImplementation(async ({ commit }) => commit(new Map()))
|
||||
;(prisma.taskDetailChangeRequest.create as jest.Mock).mockImplementation(async ({ data }) => ({
|
||||
...data,
|
||||
material,
|
||||
submittedAt: new Date(),
|
||||
}))
|
||||
|
||||
const result = await submitTaskDetailChange('material-1', {
|
||||
id: 'user-1',
|
||||
role: 'USER',
|
||||
}, [{ kind: 'delete', assetId: 'asset-1' }])
|
||||
|
||||
expect(result.mode).toBe('pending_approval')
|
||||
expect(batchEditTaskDetailImages).not.toHaveBeenCalled()
|
||||
expect(prisma.taskDetailChangeRequest.create).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('跨主图和详情图的变更在暂存前直接拒绝', async () => {
|
||||
;(getEmployeeByUserId as jest.Mock).mockResolvedValue(employee('P'))
|
||||
|
||||
await expect(submitTaskDetailChange('material-1', {
|
||||
id: 'user-1',
|
||||
role: 'USER',
|
||||
}, [
|
||||
{ kind: 'delete', assetId: 'asset-1' },
|
||||
{ kind: 'delete', assetId: 'detail-1' },
|
||||
])).rejects.toThrow('一次只能提交同一图片分组')
|
||||
|
||||
expect(withTaskDetailStagedUploads).not.toHaveBeenCalled()
|
||||
expect(prisma.taskDetailChangeRequest.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('直属主管批准后才应用暂存操作', async () => {
|
||||
const pending = {
|
||||
id: 'request-1',
|
||||
materialId: 'material-1',
|
||||
requesterUserId: 'requester-user',
|
||||
requesterName: '申请人',
|
||||
managerDingtalkUserId: 'manager-ding',
|
||||
status: 'pending_manager_approval',
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
baseMaterialUpdatedAt: material.updatedAt,
|
||||
operations: [{
|
||||
kind: 'replace',
|
||||
assetId: 'asset-1',
|
||||
stagedFile: { relativePath: 'task-detail-change-requests/request-1/new.png', fileName: 'new.png', fileSize: 10 },
|
||||
}],
|
||||
material,
|
||||
requesterEmployee: { dingtalkUserId: 'requester-ding' },
|
||||
}
|
||||
;(getEmployeeByUserId as jest.Mock).mockResolvedValue(employee('M', 'manager-ding'))
|
||||
;(prisma.taskDetailChangeRequest.findUnique as jest.Mock)
|
||||
.mockResolvedValueOnce(pending)
|
||||
.mockResolvedValueOnce({ ...pending, status: 'applied' })
|
||||
;(prisma.taskDetailChangeRequest.updateMany as jest.Mock).mockResolvedValue({ count: 1 })
|
||||
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ updatedAt: material.updatedAt })
|
||||
;(prisma.taskDetailChangeRequest.update as jest.Mock).mockResolvedValue({})
|
||||
|
||||
const result = await decideTaskDetailChange('request-1', {
|
||||
id: 'manager-user',
|
||||
role: 'USER',
|
||||
}, 'approve')
|
||||
|
||||
expect(result.status).toBe('applied')
|
||||
expect(batchEditTaskDetailImages).toHaveBeenCalledWith(expect.objectContaining({
|
||||
operatorUserId: 'requester-user',
|
||||
expectedMaterialUpdatedAt: material.updatedAt,
|
||||
approvalRequestId: 'request-1',
|
||||
operations: [expect.objectContaining({
|
||||
kind: 'replace',
|
||||
stagedFile: expect.objectContaining({ trusted: true, fileName: 'new.png' }),
|
||||
})],
|
||||
}))
|
||||
expect(cleanupTaskDetailChangeStaging).toHaveBeenCalledWith('request-1')
|
||||
})
|
||||
|
||||
test('系统管理员可以审计查看但不能代替直属主管批准', async () => {
|
||||
const pending = {
|
||||
id: 'request-manager-only',
|
||||
materialId: 'material-1',
|
||||
requesterUserId: 'requester-user',
|
||||
managerDingtalkUserId: 'manager-ding',
|
||||
status: 'pending_manager_approval',
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
material,
|
||||
requesterEmployee: { dingtalkUserId: 'requester-ding' },
|
||||
}
|
||||
;(getEmployeeByUserId as jest.Mock).mockResolvedValue(employee('M', 'other-manager-ding'))
|
||||
;(prisma.taskDetailChangeRequest.findUnique as jest.Mock).mockResolvedValue(pending)
|
||||
|
||||
await expect(decideTaskDetailChange('request-manager-only', {
|
||||
id: 'admin-user',
|
||||
role: 'ADMIN',
|
||||
}, 'approve')).rejects.toThrow('只有申请人的直属主管可以确认')
|
||||
|
||||
expect(prisma.taskDetailChangeRequest.updateMany).not.toHaveBeenCalled()
|
||||
expect(batchEditTaskDetailImages).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('批准时正式素材已变化会停止应用、清理暂存并通知申请人', async () => {
|
||||
const pending = {
|
||||
id: 'request-stale',
|
||||
materialId: 'material-1',
|
||||
requesterUserId: 'requester-user',
|
||||
requesterName: '申请人',
|
||||
managerDingtalkUserId: 'manager-ding',
|
||||
status: 'pending_manager_approval',
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
baseMaterialUpdatedAt: material.updatedAt,
|
||||
operations: [{ kind: 'delete', assetId: 'asset-1' }],
|
||||
material,
|
||||
requesterEmployee: { dingtalkUserId: 'requester-ding' },
|
||||
}
|
||||
;(getEmployeeByUserId as jest.Mock).mockResolvedValue(employee('M', 'manager-ding'))
|
||||
;(prisma.taskDetailChangeRequest.findUnique as jest.Mock)
|
||||
.mockResolvedValueOnce(pending)
|
||||
.mockResolvedValueOnce({ ...pending, status: 'apply_failed' })
|
||||
;(prisma.taskDetailChangeRequest.updateMany as jest.Mock).mockResolvedValue({ count: 1 })
|
||||
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
|
||||
updatedAt: new Date(material.updatedAt.getTime() + 1000),
|
||||
})
|
||||
;(prisma.taskDetailChangeRequest.update as jest.Mock).mockResolvedValue({})
|
||||
|
||||
await expect(decideTaskDetailChange('request-stale', {
|
||||
id: 'manager-user',
|
||||
role: 'USER',
|
||||
}, 'approve')).rejects.toThrow('正式素材在审批期间已更新')
|
||||
|
||||
expect(batchEditTaskDetailImages).not.toHaveBeenCalled()
|
||||
expect(prisma.taskDetailChangeRequest.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({ status: 'apply_failed' }),
|
||||
}))
|
||||
expect(cleanupTaskDetailChangeStaging).toHaveBeenCalledWith('request-stale')
|
||||
expect(queueTaskDetailChangeNotification).toHaveBeenCalledWith(expect.objectContaining({
|
||||
eventType: 'task_detail_change.apply_failed',
|
||||
}))
|
||||
})
|
||||
|
||||
test('超过 24 小时未处理的申请自动否决并清理暂存', async () => {
|
||||
;(prisma.taskDetailChangeRequest.findMany as jest.Mock).mockResolvedValue([{ id: 'request-expired' }])
|
||||
;(prisma.taskDetailChangeRequest.updateMany as jest.Mock).mockResolvedValue({ count: 1 })
|
||||
;(prisma.taskDetailChangeRequest.findUnique as jest.Mock).mockResolvedValue({
|
||||
id: 'request-expired',
|
||||
status: 'expired',
|
||||
requesterUserId: 'requester-user',
|
||||
requesterName: '申请人',
|
||||
requesterEmployee: { dingtalkUserId: 'requester-ding' },
|
||||
material,
|
||||
})
|
||||
|
||||
await expect(expireDueTaskDetailChanges()).resolves.toBe(1)
|
||||
expect(cleanupTaskDetailChangeStaging).toHaveBeenCalledWith('request-expired')
|
||||
expect(queueTaskDetailChangeNotification).toHaveBeenCalledWith(expect.objectContaining({
|
||||
eventType: 'task_detail_change.expired',
|
||||
}))
|
||||
})
|
||||
|
||||
test('审批单存在但主管通知 outbox 缺失时会补偿排队', async () => {
|
||||
;(prisma.taskDetailChangeRequest.findMany as jest.Mock).mockResolvedValue([{
|
||||
id: 'request-missing-notice',
|
||||
managerUserId: 'manager-user',
|
||||
managerDingtalkUserId: 'manager-ding',
|
||||
managerName: '直属主管',
|
||||
requesterName: '申请人',
|
||||
material,
|
||||
operations: [{ kind: 'delete', assetId: 'asset-1' }],
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
}])
|
||||
|
||||
await expect(recoverMissingTaskDetailChangeRequestNotifications()).resolves.toBe(1)
|
||||
expect(queueTaskDetailChangeNotification).toHaveBeenCalledWith(expect.objectContaining({
|
||||
eventType: 'task_detail_change.requested',
|
||||
businessKey: 'task-detail-change:request-missing-notice:requested',
|
||||
}))
|
||||
})
|
||||
|
||||
test('陈旧 applying 会转失败、清理暂存并通知申请人', async () => {
|
||||
const now = new Date('2026-08-28T12:00:00.000Z')
|
||||
;(prisma.taskDetailChangeRequest.findMany as jest.Mock).mockResolvedValue([{ id: 'request-stale-applying' }])
|
||||
;(prisma.taskDetailChangeRequest.updateMany as jest.Mock).mockResolvedValue({ count: 1 })
|
||||
;(prisma.taskDetailChangeRequest.findUnique as jest.Mock).mockResolvedValue({
|
||||
id: 'request-stale-applying',
|
||||
status: 'apply_failed',
|
||||
requesterUserId: 'requester-user',
|
||||
requesterName: '申请人',
|
||||
requesterEmployee: { dingtalkUserId: 'requester-ding' },
|
||||
material,
|
||||
})
|
||||
|
||||
await expect(recoverStaleApplyingTaskDetailChanges(now)).resolves.toBe(1)
|
||||
expect(prisma.taskDetailChangeRequest.updateMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({ id: 'request-stale-applying', status: 'applying' }),
|
||||
data: expect.objectContaining({ status: 'apply_failed' }),
|
||||
}))
|
||||
expect(cleanupTaskDetailChangeStaging).toHaveBeenCalledWith('request-stale-applying')
|
||||
expect(queueTaskDetailChangeNotification).toHaveBeenCalledWith(expect.objectContaining({
|
||||
eventType: 'task_detail_change.apply_failed',
|
||||
}))
|
||||
})
|
||||
})
|
||||
123
lib/__tests__/task-detail-change-request-routes.test.ts
Normal file
123
lib/__tests__/task-detail-change-request-routes.test.ts
Normal file
@ -0,0 +1,123 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { getServerSession } from 'next-auth'
|
||||
|
||||
import {
|
||||
GET as listChanges,
|
||||
POST as submitChange,
|
||||
} from '@/app/api/materials/[id]/assets/change-requests/route'
|
||||
import { GET as getChange } from '@/app/api/task-detail-change-requests/[id]/route'
|
||||
import { POST as decideChange } from '@/app/api/task-detail-change-requests/[id]/decision/route'
|
||||
import {
|
||||
decideTaskDetailChange,
|
||||
getTaskDetailChangeRequest,
|
||||
listTaskDetailChanges,
|
||||
submitTaskDetailChange,
|
||||
TaskDetailChangeApprovalError,
|
||||
} from '@/lib/task-detail-change-approval'
|
||||
|
||||
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
|
||||
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
|
||||
jest.mock('@/lib/task-detail-change-approval', () => ({
|
||||
TaskDetailChangeApprovalError: class TaskDetailChangeApprovalError extends Error {
|
||||
statusCode: number
|
||||
constructor(message: string, statusCode = 400) {
|
||||
super(message)
|
||||
this.statusCode = statusCode
|
||||
}
|
||||
},
|
||||
decideTaskDetailChange: jest.fn(),
|
||||
getTaskDetailChangeRequest: jest.fn(),
|
||||
listTaskDetailChanges: jest.fn(),
|
||||
submitTaskDetailChange: jest.fn(),
|
||||
}))
|
||||
|
||||
const actor = { id: 'user-1', role: 'USER', isSuperAdmin: false }
|
||||
|
||||
function requestWith(body: unknown) {
|
||||
return { json: async () => body } as any
|
||||
}
|
||||
|
||||
describe('任务详情图片变更审批 API', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
test('四个入口都拒绝未登录请求', async () => {
|
||||
;(getServerSession as jest.Mock).mockResolvedValue(null)
|
||||
|
||||
const responses = await Promise.all([
|
||||
listChanges({} as any, { params: { id: 'material-1' } }),
|
||||
submitChange(requestWith({ operations: [] }), { params: { id: 'material-1' } }),
|
||||
getChange({} as any, { params: { id: 'request-1' } }),
|
||||
decideChange(requestWith({ decision: 'approve' }), { params: { id: 'request-1' } }),
|
||||
])
|
||||
|
||||
expect(responses.map((response) => response.status)).toEqual([401, 401, 401, 401])
|
||||
expect(submitTaskDetailChange).not.toHaveBeenCalled()
|
||||
expect(decideTaskDetailChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('提交和列表使用当前登录人并返回审批结果', async () => {
|
||||
;(getServerSession as jest.Mock).mockResolvedValue({ user: actor })
|
||||
;(submitTaskDetailChange as jest.Mock).mockResolvedValue({
|
||||
mode: 'pending_approval',
|
||||
request: { id: 'request-1', status: 'pending_manager_approval' },
|
||||
})
|
||||
;(listTaskDetailChanges as jest.Mock).mockResolvedValue([{ id: 'request-1' }])
|
||||
const operations = [{ kind: 'delete', assetId: 'asset-1' }]
|
||||
|
||||
const submitted = await submitChange(requestWith({ operations }), { params: { id: 'material-1' } })
|
||||
const listed = await listChanges({} as any, { params: { id: 'material-1' } })
|
||||
|
||||
expect(submitted.status).toBe(200)
|
||||
await expect(submitted.json()).resolves.toMatchObject({
|
||||
success: true,
|
||||
mode: 'pending_approval',
|
||||
request: { id: 'request-1' },
|
||||
})
|
||||
expect(submitTaskDetailChange).toHaveBeenCalledWith('material-1', actor, operations)
|
||||
expect(listTaskDetailChanges).toHaveBeenCalledWith('material-1', actor)
|
||||
await expect(listed.json()).resolves.toEqual({ success: true, data: [{ id: 'request-1' }] })
|
||||
})
|
||||
|
||||
test('详情与批准接口透传审批服务结果', async () => {
|
||||
;(getServerSession as jest.Mock).mockResolvedValue({ user: actor })
|
||||
;(getTaskDetailChangeRequest as jest.Mock).mockResolvedValue({ id: 'request-1', canDecide: true })
|
||||
;(decideTaskDetailChange as jest.Mock).mockResolvedValue({ status: 'applied' })
|
||||
|
||||
const detail = await getChange({} as any, { params: { id: 'request-1' } })
|
||||
const decision = await decideChange(
|
||||
requestWith({ decision: 'approve' }),
|
||||
{ params: { id: 'request-1' } },
|
||||
)
|
||||
|
||||
await expect(detail.json()).resolves.toEqual({ success: true, data: { id: 'request-1', canDecide: true } })
|
||||
await expect(decision.json()).resolves.toEqual({ success: true, data: { status: 'applied' } })
|
||||
expect(decideTaskDetailChange).toHaveBeenCalledWith('request-1', actor, 'approve')
|
||||
})
|
||||
|
||||
test('非法决定在路由层返回 400', async () => {
|
||||
;(getServerSession as jest.Mock).mockResolvedValue({ user: actor })
|
||||
|
||||
const response = await decideChange(
|
||||
requestWith({ decision: 'later' }),
|
||||
{ params: { id: 'request-1' } },
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(decideTaskDetailChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('审批业务错误保留状态码和用户可读文案', async () => {
|
||||
;(getServerSession as jest.Mock).mockResolvedValue({ user: actor })
|
||||
;(getTaskDetailChangeRequest as jest.Mock).mockRejectedValue(
|
||||
new TaskDetailChangeApprovalError('您无权查看这条图片变更申请', 403),
|
||||
)
|
||||
|
||||
const response = await getChange({} as any, { params: { id: 'request-1' } })
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
await expect(response.json()).resolves.toEqual({ success: false, error: '您无权查看这条图片变更申请' })
|
||||
})
|
||||
})
|
||||
@ -37,6 +37,14 @@ describe('任务-详情预览名解析', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('服务端长图切片命名可解析 sliceIndex', () => {
|
||||
const name = buildServerPreviewFileName('IMG_2002', 3, 1)
|
||||
expect(parseTaskDetailPreviewFileName(name)).toEqual({
|
||||
label: 'IMG_2002',
|
||||
sliceIndex: 1,
|
||||
})
|
||||
})
|
||||
|
||||
test('非 webp 返回 null', () => {
|
||||
expect(parseTaskDetailPreviewFileName('photo.jpg')).toBeNull()
|
||||
})
|
||||
@ -65,6 +73,18 @@ describe('任务-详情预览行切分', () => {
|
||||
expect(runs![1].rows.map((item: any) => item.id)).toEqual(['c', 'd'])
|
||||
})
|
||||
|
||||
test('历史同锚旧 __srv 无 slice 预览按相邻行兼容归并', () => {
|
||||
const oldSrv = '1_02__srv-1785466728480-11-abc1234.webp'
|
||||
const runs = groupPreviewRowsIntoRuns([
|
||||
row('a', oldSrv, 0),
|
||||
row('b', oldSrv, 1),
|
||||
row('c', '1_03-preview.webp', 2),
|
||||
])
|
||||
expect(runs).not.toBeNull()
|
||||
expect(runs!.length).toBe(2)
|
||||
expect(runs![0].rows.map((item: any) => item.id)).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
test('出现无法解析的名字时整体放弃切分', () => {
|
||||
expect(groupPreviewRowsIntoRuns([row('a', 'unknown.png')])).toBeNull()
|
||||
})
|
||||
@ -114,6 +134,19 @@ describe('run 与 ZIP 条目的名字匹配', () => {
|
||||
])!
|
||||
expect(() => matchRunsToZipEntries(runs, ['cover.jpg', 'COVER.png'])).toThrow()
|
||||
})
|
||||
|
||||
test('真实形态:1_02 两个旧 __srv preview 对一个 ZIP 条目', () => {
|
||||
const oldSrv = '1_02__srv-1785466728480-11-abc1234.webp'
|
||||
const runs = groupPreviewRowsIntoRuns([
|
||||
row('a', '1_01-preview.webp', 0),
|
||||
row('b', oldSrv, 1),
|
||||
row('c', oldSrv, 2),
|
||||
row('d', '1_03-preview.webp', 3),
|
||||
])!
|
||||
expect(runs.length).toBe(3)
|
||||
expect(runs[1].rows.map((item: any) => item.id)).toEqual(['b', 'c'])
|
||||
expect(matchRunsToZipEntries(runs, ['1_01.jpg', '1_02.jpg', '1_03.jpg'])).toEqual([0, 1, 2])
|
||||
})
|
||||
})
|
||||
|
||||
describe('旧代预览名(detail-preview-{时间戳}-{序号}-...)兼容', () => {
|
||||
@ -164,6 +197,22 @@ describe('旧代预览名(detail-preview-{时间戳}-{序号}-...)兼容', (
|
||||
expect(aligned![2]).toBe(2)
|
||||
})
|
||||
|
||||
test('混合旧代序号与历史无 slice 的 __srv 双切片时仍可对齐', () => {
|
||||
const oldSrv = '1_02__srv-1785466728480-11-abc1234.webp'
|
||||
const rows = [
|
||||
row('a', legacyName(0), 0),
|
||||
row('b', oldSrv, 1),
|
||||
row('c', oldSrv, 2),
|
||||
row('d', legacyName(2), 3),
|
||||
]
|
||||
const runs = buildRunsWithLegacyIndex(rows)
|
||||
expect(runs).not.toBeNull()
|
||||
expect(runs!.length).toBe(3)
|
||||
expect(runs![1].rows.map((item: any) => item.id)).toEqual(['b', 'c'])
|
||||
const aligned = alignRunsToZipEntriesByOrder(runs!, ['1_01.jpg', '1_02.jpg', '1_03.jpg'])
|
||||
expect(aligned).toEqual([0, 1, 2])
|
||||
})
|
||||
|
||||
test('删除后再编辑:条目与序号同删,相对顺序对齐依然成立', () => {
|
||||
// 原 4 张删掉序号 1:剩条目 3 个、旧代序号 {0,2,3}
|
||||
const rows = [0, 2, 3].map((index) => row(`r${index}`, legacyName(index), index))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
722
lib/task-detail-change-approval.ts
Normal file
722
lib/task-detail-change-approval.ts
Normal file
@ -0,0 +1,722 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
|
||||
import { getEmployeeByUserId, BINDING_STATUS } from '@/lib/employee-directory'
|
||||
import { canUserEditTaskDetailAssets } from '@/lib/user-permission-inherit'
|
||||
import { canManageMaterial } from '@/lib/material-authorization'
|
||||
import { isProductPackageType } from '@/lib/material-type-mapper'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getPublicAppOrigin } from '@/lib/public-app-url'
|
||||
import { buildDingtalkEntryPath } from '@/lib/safe-redirect'
|
||||
import { isSystemAdmin } from '@/lib/department-permission'
|
||||
import {
|
||||
cleanupTaskDetailChangeStaging,
|
||||
resolveTaskDetailStagedFile,
|
||||
withTaskDetailStagedUploads,
|
||||
type TaskDetailStagedFile,
|
||||
} from '@/lib/task-detail-change-staging'
|
||||
import { queueTaskDetailChangeNotification } from '@/lib/task-detail-change-notifications'
|
||||
import { batchEditTaskDetailImages } from '@/lib/task-detail-asset-edit'
|
||||
|
||||
const REQUEST_STATUS = {
|
||||
PENDING: 'pending_manager_approval',
|
||||
APPLYING: 'applying',
|
||||
APPLIED: 'applied',
|
||||
REJECTED: 'rejected',
|
||||
EXPIRED: 'expired',
|
||||
APPLY_FAILED: 'apply_failed',
|
||||
} as const
|
||||
|
||||
const APPROVAL_WINDOW_MS = 24 * 60 * 60 * 1000
|
||||
const STALE_APPLYING_MS = 30 * 60 * 1000
|
||||
const MAX_OPERATIONS = 100
|
||||
|
||||
export class TaskDetailChangeApprovalError extends Error {
|
||||
constructor(message: string, public statusCode = 400) {
|
||||
super(message)
|
||||
this.name = 'TaskDetailChangeApprovalError'
|
||||
}
|
||||
}
|
||||
|
||||
export type TaskDetailChangeOperationInput =
|
||||
| { kind: 'delete'; assetId: string }
|
||||
| { kind: 'rename'; assetId: string; newFileName: string }
|
||||
| { kind: 'replace'; assetId: string; uploadId: string }
|
||||
| { kind: 'append'; group: 'main' | 'sku'; uploadId: string }
|
||||
| {
|
||||
kind: 'insert'
|
||||
group: 'detail'
|
||||
uploadId: string
|
||||
afterAssetId: string
|
||||
beforeAssetId: string
|
||||
}
|
||||
|
||||
type StoredOperation = Omit<TaskDetailChangeOperationInput, 'uploadId'> & {
|
||||
stagedFile?: TaskDetailStagedFile
|
||||
}
|
||||
|
||||
type Actor = {
|
||||
id: string
|
||||
role?: string | null
|
||||
isSuperAdmin?: boolean
|
||||
}
|
||||
|
||||
function clean(value: unknown) {
|
||||
const text = String(value ?? '').trim()
|
||||
return text || null
|
||||
}
|
||||
|
||||
function normalizeOperations(value: unknown): TaskDetailChangeOperationInput[] {
|
||||
const rows = Array.isArray(value) ? value : []
|
||||
if (rows.length === 0) throw new TaskDetailChangeApprovalError('图片变更不能为空')
|
||||
if (rows.length > MAX_OPERATIONS) {
|
||||
throw new TaskDetailChangeApprovalError(`一次最多提交 ${MAX_OPERATIONS} 项图片变更`)
|
||||
}
|
||||
|
||||
const operations: TaskDetailChangeOperationInput[] = []
|
||||
const targetAssetIds = new Set<string>()
|
||||
for (const raw of rows) {
|
||||
const row = raw as Record<string, unknown>
|
||||
const kind = clean(row.kind)
|
||||
if (kind === 'delete') {
|
||||
const assetId = clean(row.assetId)
|
||||
if (!assetId) throw new TaskDetailChangeApprovalError('删除操作缺少图片标识')
|
||||
if (targetAssetIds.has(assetId)) throw new TaskDetailChangeApprovalError('同一图片不能重复修改')
|
||||
targetAssetIds.add(assetId)
|
||||
operations.push({ kind, assetId })
|
||||
continue
|
||||
}
|
||||
if (kind === 'rename') {
|
||||
const assetId = clean(row.assetId)
|
||||
const newFileName = clean(row.newFileName)
|
||||
if (!assetId || !newFileName) throw new TaskDetailChangeApprovalError('改名操作参数不完整')
|
||||
if (targetAssetIds.has(assetId)) throw new TaskDetailChangeApprovalError('同一图片不能重复修改')
|
||||
targetAssetIds.add(assetId)
|
||||
operations.push({ kind, assetId, newFileName })
|
||||
continue
|
||||
}
|
||||
if (kind === 'replace') {
|
||||
const assetId = clean(row.assetId)
|
||||
const uploadId = clean(row.uploadId)
|
||||
if (!assetId || !uploadId) throw new TaskDetailChangeApprovalError('替换操作参数不完整')
|
||||
if (targetAssetIds.has(assetId)) throw new TaskDetailChangeApprovalError('同一图片不能重复修改')
|
||||
targetAssetIds.add(assetId)
|
||||
operations.push({ kind, assetId, uploadId })
|
||||
continue
|
||||
}
|
||||
if (kind === 'append') {
|
||||
const group = clean(row.group)
|
||||
const uploadId = clean(row.uploadId)
|
||||
if ((group !== 'main' && group !== 'sku') || !uploadId) {
|
||||
throw new TaskDetailChangeApprovalError('追加图片操作参数不完整')
|
||||
}
|
||||
operations.push({ kind, group, uploadId })
|
||||
continue
|
||||
}
|
||||
if (kind === 'insert') {
|
||||
const group = clean(row.group)
|
||||
const uploadId = clean(row.uploadId)
|
||||
const afterAssetId = clean(row.afterAssetId)
|
||||
const beforeAssetId = clean(row.beforeAssetId)
|
||||
if (group !== 'detail' || !uploadId || !afterAssetId || !beforeAssetId) {
|
||||
throw new TaskDetailChangeApprovalError('详情图插入位置或图片参数不完整')
|
||||
}
|
||||
operations.push({ kind, group, uploadId, afterAssetId, beforeAssetId })
|
||||
continue
|
||||
}
|
||||
throw new TaskDetailChangeApprovalError(`不支持的图片变更类型:${kind || '空'}`)
|
||||
}
|
||||
return operations
|
||||
}
|
||||
|
||||
function assertSingleOperationGroup(
|
||||
operations: TaskDetailChangeOperationInput[],
|
||||
assets: Array<{ id: string; assetType: string }>,
|
||||
) {
|
||||
const assetGroups = new Map<string, 'main' | 'sku' | 'detail'>()
|
||||
for (const asset of assets) {
|
||||
if (asset.assetType === 'main-preview') assetGroups.set(asset.id, 'main')
|
||||
if (asset.assetType === 'sku-preview') assetGroups.set(asset.id, 'sku')
|
||||
if (asset.assetType === 'detail-preview') assetGroups.set(asset.id, 'detail')
|
||||
}
|
||||
const groups = new Set<'main' | 'sku' | 'detail'>()
|
||||
for (const operation of operations) {
|
||||
if (operation.kind === 'append') {
|
||||
groups.add(operation.group)
|
||||
continue
|
||||
}
|
||||
if (operation.kind === 'insert') {
|
||||
if (
|
||||
assetGroups.get(operation.afterAssetId) !== 'detail'
|
||||
|| assetGroups.get(operation.beforeAssetId) !== 'detail'
|
||||
) {
|
||||
throw new TaskDetailChangeApprovalError('详情图插入位置不存在或不属于该素材', 404)
|
||||
}
|
||||
groups.add('detail')
|
||||
continue
|
||||
}
|
||||
const group = assetGroups.get(operation.assetId)
|
||||
if (!group) {
|
||||
throw new TaskDetailChangeApprovalError('要修改的图片不存在或不属于该素材', 404)
|
||||
}
|
||||
groups.add(group)
|
||||
}
|
||||
if (groups.size !== 1) {
|
||||
throw new TaskDetailChangeApprovalError('一次只能提交同一图片分组(主图、SKU图或详情图)的变更,请分组提交')
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAuthorizedContext(materialId: string, actor: Actor) {
|
||||
const [material, employee] = await Promise.all([
|
||||
prisma.material.findFirst({
|
||||
where: { id: materialId, deletedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
materialType: true,
|
||||
taskStatus: true,
|
||||
taskNo: true,
|
||||
productModel: true,
|
||||
updatedAt: true,
|
||||
assets: {
|
||||
where: { assetType: { in: ['main-preview', 'sku-preview', 'detail-preview'] } },
|
||||
select: { id: true, assetType: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
getEmployeeByUserId(actor.id),
|
||||
])
|
||||
if (!material) throw new TaskDetailChangeApprovalError('素材不存在或已删除', 404)
|
||||
if (!isProductPackageType(material.materialType)) {
|
||||
throw new TaskDetailChangeApprovalError('只有任务-详情素材支持图片变更')
|
||||
}
|
||||
if (material.taskStatus !== 'completed') {
|
||||
throw new TaskDetailChangeApprovalError('素材尚未完成上传,暂不能修改图片')
|
||||
}
|
||||
|
||||
let allowed = await canManageMaterial(
|
||||
{ id: actor.id, role: actor.role, isSuperAdmin: actor.isSuperAdmin },
|
||||
{ id: material.id, userId: material.userId },
|
||||
)
|
||||
if (!allowed) allowed = await canUserEditTaskDetailAssets(actor.id)
|
||||
if (!allowed) throw new TaskDetailChangeApprovalError('您没有权限修改此素材', 403)
|
||||
return { material, employee }
|
||||
}
|
||||
|
||||
function employeeSnapshot(employee: NonNullable<Awaited<ReturnType<typeof getEmployeeByUserId>>>) {
|
||||
return {
|
||||
employeeProfileId: employee.employeeProfileId,
|
||||
userId: employee.userId,
|
||||
displayName: employee.displayName,
|
||||
jobNumber: employee.jobNumber,
|
||||
dingtalkUserId: employee.dingtalkUserId,
|
||||
departmentName: employee.departmentName,
|
||||
departmentPath: employee.departmentPath,
|
||||
positionTitle: employee.positionTitle,
|
||||
positionLevel: employee.positionLevel,
|
||||
levelType: employee.levelType,
|
||||
isLeadership: employee.isLeadership,
|
||||
managerName: employee.managerName,
|
||||
managerDingtalkUserId: employee.managerDingtalkUserId,
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveManager(employee: NonNullable<Awaited<ReturnType<typeof getEmployeeByUserId>>>) {
|
||||
const managerDingtalkUserId = clean(employee.managerDingtalkUserId)
|
||||
if (!managerDingtalkUserId) {
|
||||
throw new TaskDetailChangeApprovalError('花名册没有记录直属主管,暂不能提交图片变更申请', 409)
|
||||
}
|
||||
const manager = await prisma.performanceSurveyEmployeeProfile.findUnique({
|
||||
where: { dingtalkUserId: managerDingtalkUserId },
|
||||
include: {
|
||||
userBindings: {
|
||||
where: { status: BINDING_STATUS.ACTIVE },
|
||||
select: { userId: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
if (!manager || manager.employmentState !== 'ACTIVE') {
|
||||
throw new TaskDetailChangeApprovalError('直属主管账号无有效在职档案,暂不能提交图片变更申请', 409)
|
||||
}
|
||||
return {
|
||||
employee: manager,
|
||||
userId: manager.userBindings[0]?.userId || null,
|
||||
dingtalkUserId: managerDingtalkUserId,
|
||||
name: manager.displayName || manager.realName || employee.managerName || '直属主管',
|
||||
}
|
||||
}
|
||||
|
||||
function isDirectApplyActor(actor: Actor, employee: Awaited<ReturnType<typeof getEmployeeByUserId>>) {
|
||||
const positionLevel = employee?.positionLevel?.trim().toUpperCase()
|
||||
return Boolean(
|
||||
actor.isSuperAdmin
|
||||
|| isSystemAdmin(String(actor.role || ''))
|
||||
|| employee?.levelType?.toUpperCase() === 'M'
|
||||
|| positionLevel?.startsWith('M')
|
||||
)
|
||||
}
|
||||
|
||||
function actionUrl(requestId: string) {
|
||||
return `${getPublicAppOrigin()}${buildDingtalkEntryPath(`/task-detail-change-requests/${requestId}`)}`
|
||||
}
|
||||
|
||||
async function queueManagerNotification(request: {
|
||||
id: string
|
||||
managerUserId: string | null
|
||||
managerDingtalkUserId: string
|
||||
managerName: string
|
||||
requesterName: string
|
||||
material: { taskNo: string | null; productModel: string }
|
||||
operations: unknown[]
|
||||
expiresAt: Date
|
||||
}) {
|
||||
await queueTaskDetailChangeNotification({
|
||||
requestId: request.id,
|
||||
recipientUserId: request.managerUserId,
|
||||
dingtalkUserId: request.managerDingtalkUserId,
|
||||
recipientName: request.managerName,
|
||||
eventType: 'task_detail_change.requested',
|
||||
businessKey: `task-detail-change:${request.id}:requested`,
|
||||
title: '任务详情图片变更待确认',
|
||||
content: [
|
||||
'### ◆ 图片变更待确认',
|
||||
'',
|
||||
`**素材:** ${request.material.taskNo || request.material.productModel}`,
|
||||
`**提交人:** ${request.requesterName}`,
|
||||
`**变更数量:** ${request.operations.length} 项`,
|
||||
`**截止时间:** ${request.expiresAt.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}`,
|
||||
'',
|
||||
'> 未确认前正式素材不会改变;超过截止时间将自动否决。',
|
||||
].join('\n'),
|
||||
actionUrl: actionUrl(request.id),
|
||||
actionTitle: '立即确认',
|
||||
})
|
||||
}
|
||||
|
||||
async function queueRequesterResultNotification(request: any, label: string) {
|
||||
const dingtalkUserId = clean(request.requesterEmployee?.dingtalkUserId)
|
||||
if (!dingtalkUserId) return
|
||||
await queueTaskDetailChangeNotification({
|
||||
requestId: request.id,
|
||||
recipientUserId: request.requesterUserId,
|
||||
dingtalkUserId,
|
||||
recipientName: request.requesterName,
|
||||
eventType: `task_detail_change.${request.status}`,
|
||||
businessKey: `task-detail-change:${request.id}:${request.status}`,
|
||||
title: '任务详情图片变更结果',
|
||||
content: `您的图片变更申请已${label}。\n\n> 素材:${request.material.taskNo || request.material.productModel}`,
|
||||
actionUrl: actionUrl(request.id),
|
||||
actionTitle: '查看结果',
|
||||
})
|
||||
}
|
||||
|
||||
function storeOperations(
|
||||
operations: TaskDetailChangeOperationInput[],
|
||||
staged: Map<string, TaskDetailStagedFile>,
|
||||
): StoredOperation[] {
|
||||
return operations.map((operation) => {
|
||||
if (!('uploadId' in operation)) return operation as StoredOperation
|
||||
const stagedFile = staged.get(operation.uploadId)
|
||||
if (!stagedFile) throw new TaskDetailChangeApprovalError('图片持久暂存结果缺失', 500)
|
||||
const { uploadId: _uploadId, ...rest } = operation
|
||||
return { ...rest, stagedFile } as StoredOperation
|
||||
})
|
||||
}
|
||||
|
||||
function applyOperations(requestId: string, operations: StoredOperation[]) {
|
||||
return operations.map((operation) => {
|
||||
if (!operation.stagedFile) return operation
|
||||
return {
|
||||
...operation,
|
||||
stagedFile: resolveTaskDetailStagedFile(requestId, operation.stagedFile),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function submitTaskDetailChange(
|
||||
materialId: string,
|
||||
actor: Actor,
|
||||
rawOperations: unknown,
|
||||
) {
|
||||
const operations = normalizeOperations(rawOperations)
|
||||
const { material, employee } = await loadAuthorizedContext(materialId, actor)
|
||||
assertSingleOperationGroup(operations, material.assets)
|
||||
if (isDirectApplyActor(actor, employee)) {
|
||||
const data = await batchEditTaskDetailImages({
|
||||
materialId,
|
||||
operatorUserId: actor.id,
|
||||
operatorRole: actor.role,
|
||||
operations: operations as any,
|
||||
})
|
||||
return { mode: 'applied' as const, data }
|
||||
}
|
||||
if (!employee) {
|
||||
throw new TaskDetailChangeApprovalError('当前账号没有匹配花名册,暂不能提交主管审批', 403)
|
||||
}
|
||||
const manager = await resolveManager(employee)
|
||||
const requestId = randomUUID()
|
||||
const expiresAt = new Date(Date.now() + APPROVAL_WINDOW_MS)
|
||||
const uploadIds = operations.filter((item): item is Extract<TaskDetailChangeOperationInput, { uploadId: string }> => (
|
||||
'uploadId' in item
|
||||
)).map((item) => item.uploadId)
|
||||
|
||||
const request = await withTaskDetailStagedUploads({
|
||||
requestId,
|
||||
ownerUserId: actor.id,
|
||||
uploadIds,
|
||||
commit: async (staged) => {
|
||||
const storedOperations = storeOperations(operations, staged)
|
||||
return prisma.taskDetailChangeRequest.create({
|
||||
data: {
|
||||
id: requestId,
|
||||
materialId,
|
||||
requesterUserId: actor.id,
|
||||
requesterEmployeeId: employee.employeeProfileId,
|
||||
requesterName: employee.displayName,
|
||||
requesterSnapshot: employeeSnapshot(employee) as any,
|
||||
requesterPositionLevel: employee.positionLevel,
|
||||
requesterLevelType: employee.levelType,
|
||||
requesterIsLeadership: employee.isLeadership,
|
||||
managerEmployeeId: manager.employee.id,
|
||||
managerUserId: manager.userId,
|
||||
managerDingtalkUserId: manager.dingtalkUserId,
|
||||
managerName: manager.name,
|
||||
managerSnapshot: {
|
||||
id: manager.employee.id,
|
||||
dingtalkUserId: manager.employee.dingtalkUserId,
|
||||
jobNumber: manager.employee.jobNumber,
|
||||
displayName: manager.name,
|
||||
departmentPath: manager.employee.departmentPath,
|
||||
positionLevel: manager.employee.positionLevel,
|
||||
} as any,
|
||||
status: REQUEST_STATUS.PENDING,
|
||||
operations: storedOperations as any,
|
||||
baseMaterialUpdatedAt: material.updatedAt,
|
||||
expiresAt,
|
||||
},
|
||||
include: { material: { select: { taskNo: true, productModel: true } } },
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
await queueManagerNotification({
|
||||
...request,
|
||||
operations,
|
||||
expiresAt,
|
||||
}).catch((error) => {
|
||||
console.error('[task-detail-change] 审批单已创建,但主管通知排队失败:', error)
|
||||
})
|
||||
return {
|
||||
mode: 'pending_approval' as const,
|
||||
request: {
|
||||
id: request.id,
|
||||
status: request.status,
|
||||
managerName: request.managerName,
|
||||
expiresAt: request.expiresAt,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRequestForActor(id: string, actor: Actor) {
|
||||
const [request, employee] = await Promise.all([
|
||||
prisma.taskDetailChangeRequest.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
material: { select: { id: true, taskNo: true, productModel: true, materialType: true, updatedAt: true } },
|
||||
requesterEmployee: { select: { dingtalkUserId: true } },
|
||||
},
|
||||
}),
|
||||
getEmployeeByUserId(actor.id),
|
||||
])
|
||||
if (!request) throw new TaskDetailChangeApprovalError('图片变更申请不存在', 404)
|
||||
const canDecide = Boolean(
|
||||
employee?.dingtalkUserId && employee.dingtalkUserId === request.managerDingtalkUserId,
|
||||
)
|
||||
const canView = canDecide
|
||||
|| request.requesterUserId === actor.id
|
||||
|| actor.isSuperAdmin
|
||||
|| isSystemAdmin(String(actor.role || ''))
|
||||
if (!canView) throw new TaskDetailChangeApprovalError('您无权查看这条图片变更申请', 403)
|
||||
return { request, employee, canDecide }
|
||||
}
|
||||
|
||||
export async function getTaskDetailChangeRequest(id: string, actor: Actor) {
|
||||
const { request, canDecide } = await loadRequestForActor(id, actor)
|
||||
const operations = (Array.isArray(request.operations) ? request.operations : []) as StoredOperation[]
|
||||
return {
|
||||
id: request.id,
|
||||
status: request.status,
|
||||
requesterName: request.requesterName,
|
||||
managerName: request.managerName,
|
||||
material: request.material,
|
||||
operations: operations.map(({ stagedFile, ...operation }) => ({
|
||||
...operation,
|
||||
fileName: stagedFile?.fileName || null,
|
||||
})),
|
||||
submittedAt: request.submittedAt,
|
||||
expiresAt: request.expiresAt,
|
||||
decidedAt: request.decidedAt,
|
||||
appliedAt: request.appliedAt,
|
||||
applyError: request.applyError,
|
||||
canDecide: canDecide && request.status === REQUEST_STATUS.PENDING && request.expiresAt > new Date(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function listTaskDetailChanges(materialId: string, actor: Actor) {
|
||||
const employee = await getEmployeeByUserId(actor.id)
|
||||
const privileged = Boolean(actor.isSuperAdmin || isSystemAdmin(String(actor.role || '')))
|
||||
const rows = await prisma.taskDetailChangeRequest.findMany({
|
||||
where: {
|
||||
materialId,
|
||||
...(privileged
|
||||
? {}
|
||||
: {
|
||||
OR: [
|
||||
{ requesterUserId: actor.id },
|
||||
...(employee?.dingtalkUserId
|
||||
? [{ managerDingtalkUserId: employee.dingtalkUserId }]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
requesterName: true,
|
||||
managerName: true,
|
||||
operations: true,
|
||||
submittedAt: true,
|
||||
expiresAt: true,
|
||||
decidedAt: true,
|
||||
appliedAt: true,
|
||||
applyError: true,
|
||||
},
|
||||
})
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
operationCount: Array.isArray(row.operations) ? row.operations.length : 0,
|
||||
operations: undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
export async function decideTaskDetailChange(
|
||||
id: string,
|
||||
actor: Actor,
|
||||
decision: 'approve' | 'reject',
|
||||
) {
|
||||
const { request, canDecide } = await loadRequestForActor(id, actor)
|
||||
if (!canDecide) throw new TaskDetailChangeApprovalError('只有申请人的直属主管可以确认', 403)
|
||||
const now = new Date()
|
||||
if (request.status !== REQUEST_STATUS.PENDING) {
|
||||
throw new TaskDetailChangeApprovalError('该申请已经处理,不能重复确认', 409)
|
||||
}
|
||||
if (request.expiresAt <= now) {
|
||||
const expired = await prisma.taskDetailChangeRequest.updateMany({
|
||||
where: { id, status: REQUEST_STATUS.PENDING },
|
||||
data: { status: REQUEST_STATUS.EXPIRED, decidedAt: now },
|
||||
})
|
||||
if (expired.count === 1) {
|
||||
await cleanupTaskDetailChangeStaging(id).catch(() => undefined)
|
||||
const final = await prisma.taskDetailChangeRequest.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
material: { select: { taskNo: true, productModel: true } },
|
||||
requesterEmployee: { select: { dingtalkUserId: true } },
|
||||
},
|
||||
})
|
||||
if (final) {
|
||||
await queueRequesterResultNotification(final, '因超过 24 小时未确认而自动否决').catch(() => undefined)
|
||||
}
|
||||
}
|
||||
throw new TaskDetailChangeApprovalError('该申请已超时并自动否决', 409)
|
||||
}
|
||||
|
||||
if (decision === 'reject') {
|
||||
const updated = await prisma.taskDetailChangeRequest.updateMany({
|
||||
where: { id, status: REQUEST_STATUS.PENDING, expiresAt: { gt: now } },
|
||||
data: {
|
||||
status: REQUEST_STATUS.REJECTED,
|
||||
decidedAt: now,
|
||||
decidedByUserId: actor.id,
|
||||
},
|
||||
})
|
||||
if (updated.count !== 1) throw new TaskDetailChangeApprovalError('申请状态已变化,请刷新', 409)
|
||||
await cleanupTaskDetailChangeStaging(id).catch(() => undefined)
|
||||
const final = await prisma.taskDetailChangeRequest.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
material: { select: { taskNo: true, productModel: true } },
|
||||
requesterEmployee: { select: { dingtalkUserId: true } },
|
||||
},
|
||||
})
|
||||
if (final) await queueRequesterResultNotification(final, '被主管否决').catch(() => undefined)
|
||||
return { status: REQUEST_STATUS.REJECTED }
|
||||
}
|
||||
|
||||
const claimed = await prisma.taskDetailChangeRequest.updateMany({
|
||||
where: { id, status: REQUEST_STATUS.PENDING, expiresAt: { gt: now } },
|
||||
data: {
|
||||
status: REQUEST_STATUS.APPLYING,
|
||||
decidedAt: now,
|
||||
decidedByUserId: actor.id,
|
||||
},
|
||||
})
|
||||
if (claimed.count !== 1) throw new TaskDetailChangeApprovalError('申请状态已变化,请刷新', 409)
|
||||
|
||||
try {
|
||||
const material = await prisma.material.findUnique({
|
||||
where: { id: request.materialId },
|
||||
select: { updatedAt: true },
|
||||
})
|
||||
if (!material || material.updatedAt.getTime() !== request.baseMaterialUpdatedAt.getTime()) {
|
||||
throw new TaskDetailChangeApprovalError('正式素材在审批期间已更新,本申请已停止应用', 409)
|
||||
}
|
||||
const operations = applyOperations(
|
||||
id,
|
||||
(Array.isArray(request.operations) ? request.operations : []) as StoredOperation[],
|
||||
)
|
||||
await batchEditTaskDetailImages({
|
||||
materialId: request.materialId,
|
||||
operatorUserId: request.requesterUserId,
|
||||
expectedMaterialUpdatedAt: request.baseMaterialUpdatedAt,
|
||||
approvalRequestId: id,
|
||||
operations: operations as any,
|
||||
})
|
||||
await cleanupTaskDetailChangeStaging(id).catch(() => undefined)
|
||||
const final = await prisma.taskDetailChangeRequest.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
material: { select: { taskNo: true, productModel: true } },
|
||||
requesterEmployee: { select: { dingtalkUserId: true } },
|
||||
},
|
||||
})
|
||||
if (final) await queueRequesterResultNotification(final, '由直属主管批准并生效').catch(() => undefined)
|
||||
return { status: REQUEST_STATUS.APPLIED }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '批准后应用图片变更失败'
|
||||
await prisma.taskDetailChangeRequest.update({
|
||||
where: { id },
|
||||
data: { status: REQUEST_STATUS.APPLY_FAILED, applyError: message },
|
||||
})
|
||||
await cleanupTaskDetailChangeStaging(id).catch(() => undefined)
|
||||
const final = await prisma.taskDetailChangeRequest.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
material: { select: { taskNo: true, productModel: true } },
|
||||
requesterEmployee: { select: { dingtalkUserId: true } },
|
||||
},
|
||||
})
|
||||
if (final) {
|
||||
await queueRequesterResultNotification(final, '在批准后应用失败,请重新提交').catch(() => undefined)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function expireDueTaskDetailChanges(now = new Date()) {
|
||||
const due = await prisma.taskDetailChangeRequest.findMany({
|
||||
where: { status: REQUEST_STATUS.PENDING, expiresAt: { lte: now } },
|
||||
select: { id: true },
|
||||
orderBy: { expiresAt: 'asc' },
|
||||
take: 50,
|
||||
})
|
||||
let expired = 0
|
||||
for (const row of due) {
|
||||
const changed = await prisma.taskDetailChangeRequest.updateMany({
|
||||
where: { id: row.id, status: REQUEST_STATUS.PENDING, expiresAt: { lte: now } },
|
||||
data: { status: REQUEST_STATUS.EXPIRED, decidedAt: now },
|
||||
})
|
||||
if (changed.count !== 1) continue
|
||||
expired += 1
|
||||
await cleanupTaskDetailChangeStaging(row.id).catch(() => undefined)
|
||||
const final = await prisma.taskDetailChangeRequest.findUnique({
|
||||
where: { id: row.id },
|
||||
include: {
|
||||
material: { select: { taskNo: true, productModel: true } },
|
||||
requesterEmployee: { select: { dingtalkUserId: true } },
|
||||
},
|
||||
})
|
||||
if (final) await queueRequesterResultNotification(final, '因超过 24 小时未确认而自动否决').catch(() => undefined)
|
||||
}
|
||||
return expired
|
||||
}
|
||||
|
||||
/**
|
||||
* 补偿“审批单已持久化、通知 outbox 落库前进程退出/数据库瞬时失败”的窄窗口。
|
||||
* 唯一键保证多实例并发扫描也只会生成一条待发送通知。
|
||||
*/
|
||||
export async function recoverMissingTaskDetailChangeRequestNotifications() {
|
||||
const rows = await prisma.taskDetailChangeRequest.findMany({
|
||||
where: {
|
||||
status: REQUEST_STATUS.PENDING,
|
||||
expiresAt: { gt: new Date() },
|
||||
notifications: {
|
||||
none: { eventType: 'task_detail_change.requested' },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: 20,
|
||||
include: {
|
||||
material: { select: { taskNo: true, productModel: true } },
|
||||
},
|
||||
})
|
||||
let queued = 0
|
||||
for (const request of rows) {
|
||||
await queueManagerNotification({
|
||||
id: request.id,
|
||||
managerUserId: request.managerUserId,
|
||||
managerDingtalkUserId: request.managerDingtalkUserId,
|
||||
managerName: request.managerName,
|
||||
requesterName: request.requesterName,
|
||||
material: request.material,
|
||||
operations: Array.isArray(request.operations) ? request.operations : [],
|
||||
expiresAt: request.expiresAt,
|
||||
})
|
||||
queued += 1
|
||||
}
|
||||
return queued
|
||||
}
|
||||
|
||||
/**
|
||||
* 正式素材更新与 request=applied 在同一数据库事务提交;因此长时间停留在
|
||||
* applying 只可能是进程中断或处理超时。先转失败会让迟到事务因状态不匹配而回滚。
|
||||
*/
|
||||
export async function recoverStaleApplyingTaskDetailChanges(now = new Date()) {
|
||||
const staleBefore = new Date(now.getTime() - STALE_APPLYING_MS)
|
||||
const rows = await prisma.taskDetailChangeRequest.findMany({
|
||||
where: { status: REQUEST_STATUS.APPLYING, updatedAt: { lte: staleBefore } },
|
||||
select: { id: true },
|
||||
orderBy: { updatedAt: 'asc' },
|
||||
take: 50,
|
||||
})
|
||||
let failed = 0
|
||||
for (const row of rows) {
|
||||
const changed = await prisma.taskDetailChangeRequest.updateMany({
|
||||
where: {
|
||||
id: row.id,
|
||||
status: REQUEST_STATUS.APPLYING,
|
||||
updatedAt: { lte: staleBefore },
|
||||
},
|
||||
data: {
|
||||
status: REQUEST_STATUS.APPLY_FAILED,
|
||||
applyError: '批准后的图片应用进程中断或超时,请重新提交变更',
|
||||
},
|
||||
})
|
||||
if (changed.count !== 1) continue
|
||||
failed += 1
|
||||
await cleanupTaskDetailChangeStaging(row.id).catch(() => undefined)
|
||||
const final = await prisma.taskDetailChangeRequest.findUnique({
|
||||
where: { id: row.id },
|
||||
include: {
|
||||
material: { select: { taskNo: true, productModel: true } },
|
||||
requesterEmployee: { select: { dingtalkUserId: true } },
|
||||
},
|
||||
})
|
||||
if (final) {
|
||||
await queueRequesterResultNotification(final, '因应用进程中断或超时而失败,请重新提交').catch(() => undefined)
|
||||
}
|
||||
}
|
||||
return failed
|
||||
}
|
||||
153
lib/task-detail-change-notifications.ts
Normal file
153
lib/task-detail-change-notifications.ts
Normal file
@ -0,0 +1,153 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { submitYouyidianNotification } from '@/lib/youyidian-notification-center'
|
||||
|
||||
const STATUS = {
|
||||
PENDING: 'pending',
|
||||
PROCESSING: 'processing',
|
||||
RETRYING: 'retrying',
|
||||
SUCCEEDED: 'succeeded',
|
||||
EXHAUSTED: 'exhausted',
|
||||
} as const
|
||||
|
||||
const RETRY_DELAYS_MS = [0, 60_000, 5 * 60_000, 15 * 60_000, 30 * 60_000]
|
||||
const RECOVERY_INTERVAL_MS = 60_000
|
||||
const STALE_PROCESSING_MS = 5 * 60_000
|
||||
const deliveryTimers = new Map<string, NodeJS.Timeout>()
|
||||
let workerStarted = false
|
||||
|
||||
type QueueInput = {
|
||||
requestId: string
|
||||
recipientUserId?: string | null
|
||||
dingtalkUserId: string
|
||||
recipientName: string
|
||||
eventType: string
|
||||
businessKey: string
|
||||
title: string
|
||||
content: string
|
||||
actionUrl: string
|
||||
actionTitle: string
|
||||
}
|
||||
|
||||
function schedule(id: string, delayMs = 0) {
|
||||
const previous = deliveryTimers.get(id)
|
||||
if (previous) clearTimeout(previous)
|
||||
const timer = setTimeout(() => {
|
||||
deliveryTimers.delete(id)
|
||||
void processTaskDetailChangeNotification(id)
|
||||
}, Math.max(0, delayMs))
|
||||
timer.unref?.()
|
||||
deliveryTimers.set(id, timer)
|
||||
}
|
||||
|
||||
async function processTaskDetailChangeNotification(id: string) {
|
||||
const claimed = await prisma.taskDetailChangeNotification.updateMany({
|
||||
where: { id, status: { in: [STATUS.PENDING, STATUS.RETRYING] } },
|
||||
data: { status: STATUS.PROCESSING },
|
||||
})
|
||||
if (claimed.count !== 1) return null
|
||||
|
||||
const notification = await prisma.taskDetailChangeNotification.findUnique({ where: { id } })
|
||||
if (!notification) return null
|
||||
try {
|
||||
const result = await submitYouyidianNotification({
|
||||
idempotencyKey: `${notification.eventType}:${notification.businessKey}`,
|
||||
recipientUserId: notification.dingtalkUserId,
|
||||
title: notification.title,
|
||||
content: notification.content,
|
||||
actionUrl: notification.actionUrl,
|
||||
actionTitle: notification.eventType === 'task_detail_change.requested' ? '立即确认' : '查看结果',
|
||||
})
|
||||
await prisma.taskDetailChangeNotification.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: STATUS.SUCCEEDED,
|
||||
attemptCount: { increment: 1 },
|
||||
providerMessageId: result.id || null,
|
||||
lastError: null,
|
||||
nextRetryAt: null,
|
||||
sentAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
},
|
||||
})
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const nextAttempt = notification.attemptCount + 1
|
||||
const exhausted = nextAttempt >= notification.maxAttempts
|
||||
const retryDelay = RETRY_DELAYS_MS[Math.min(nextAttempt, RETRY_DELAYS_MS.length - 1)]
|
||||
await prisma.taskDetailChangeNotification.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: exhausted ? STATUS.EXHAUSTED : STATUS.RETRYING,
|
||||
attemptCount: { increment: 1 },
|
||||
lastError: error instanceof Error ? error.message : '统一通知中心投递失败',
|
||||
nextRetryAt: exhausted ? null : new Date(Date.now() + retryDelay),
|
||||
completedAt: exhausted ? new Date() : null,
|
||||
},
|
||||
})
|
||||
if (!exhausted) schedule(id, retryDelay)
|
||||
return { success: false }
|
||||
}
|
||||
}
|
||||
|
||||
async function recoverDueNotifications() {
|
||||
const staleBefore = new Date(Date.now() - STALE_PROCESSING_MS)
|
||||
await prisma.taskDetailChangeNotification.updateMany({
|
||||
where: { status: STATUS.PROCESSING, updatedAt: { lt: staleBefore } },
|
||||
data: { status: STATUS.RETRYING, nextRetryAt: new Date(), lastError: '通知处理超时,已自动重试' },
|
||||
})
|
||||
const rows = await prisma.taskDetailChangeNotification.findMany({
|
||||
where: {
|
||||
status: { in: [STATUS.PENDING, STATUS.RETRYING] },
|
||||
OR: [{ nextRetryAt: null }, { nextRetryAt: { lte: new Date() } }],
|
||||
},
|
||||
select: { id: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: 20,
|
||||
})
|
||||
rows.forEach((row) => schedule(row.id))
|
||||
return rows.length
|
||||
}
|
||||
|
||||
export function ensureTaskDetailChangeNotificationWorkerStarted() {
|
||||
if (workerStarted) return
|
||||
workerStarted = true
|
||||
const bootstrap = setTimeout(() => {
|
||||
void recoverDueNotifications().catch((error) => {
|
||||
console.error('[task-detail-change] 通知恢复失败:', error)
|
||||
})
|
||||
}, 1000)
|
||||
bootstrap.unref?.()
|
||||
const recovery = setInterval(() => {
|
||||
void recoverDueNotifications().catch((error) => {
|
||||
console.error('[task-detail-change] 通知周期恢复失败:', error)
|
||||
})
|
||||
}, RECOVERY_INTERVAL_MS)
|
||||
recovery.unref?.()
|
||||
}
|
||||
|
||||
export async function queueTaskDetailChangeNotification(input: QueueInput) {
|
||||
ensureTaskDetailChangeNotificationWorkerStarted()
|
||||
const row = await prisma.taskDetailChangeNotification.upsert({
|
||||
where: { eventType_businessKey: { eventType: input.eventType, businessKey: input.businessKey } },
|
||||
update: {},
|
||||
create: {
|
||||
id: randomUUID(),
|
||||
requestId: input.requestId,
|
||||
recipientUserId: input.recipientUserId || null,
|
||||
dingtalkUserId: input.dingtalkUserId,
|
||||
recipientName: input.recipientName,
|
||||
eventType: input.eventType,
|
||||
businessKey: input.businessKey,
|
||||
title: input.title,
|
||||
content: input.content,
|
||||
actionUrl: input.actionUrl,
|
||||
status: STATUS.PENDING,
|
||||
nextRetryAt: new Date(),
|
||||
},
|
||||
select: { id: true, status: true },
|
||||
})
|
||||
if ([STATUS.PENDING, STATUS.RETRYING].includes(row.status as any)) schedule(row.id)
|
||||
return row.id
|
||||
}
|
||||
131
lib/task-detail-change-staging.ts
Normal file
131
lib/task-detail-change-staging.ts
Normal file
@ -0,0 +1,131 @@
|
||||
import { constants as fsConstants } from 'fs'
|
||||
import { copyFile, lstat, mkdir, rm } from 'fs/promises'
|
||||
import path from 'path'
|
||||
|
||||
import { acquireTaskMaterialUploadSessionLocks } from '@/lib/material-upload-cross-process-lock'
|
||||
import { normalizeSafeFileName, resolveStrictChildPath } from '@/lib/path-security'
|
||||
import { cleanupConsumedUploadSession } from '@/lib/temp-cleanup'
|
||||
import { getUploadRootDir } from '@/lib/upload-paths'
|
||||
import {
|
||||
getOwnedUploadSession,
|
||||
getUploadedFile,
|
||||
isSafeUploadId,
|
||||
} from '@/lib/upload-sessions'
|
||||
|
||||
const REQUEST_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
const STAGING_FOLDER = 'task-detail-change-requests'
|
||||
|
||||
export type TaskDetailStagedFile = {
|
||||
relativePath: string
|
||||
fileName: string
|
||||
fileSize: number
|
||||
}
|
||||
|
||||
function getStagingRoot() {
|
||||
return path.join(getUploadRootDir(), STAGING_FOLDER)
|
||||
}
|
||||
|
||||
function getRequestDirectory(requestId: string) {
|
||||
if (!REQUEST_ID_PATTERN.test(String(requestId || '').trim())) {
|
||||
throw new Error('图片变更申请标识无效')
|
||||
}
|
||||
const directory = resolveStrictChildPath(getStagingRoot(), requestId)
|
||||
if (!directory) throw new Error('图片变更暂存目录无效')
|
||||
return directory
|
||||
}
|
||||
|
||||
export async function withTaskDetailStagedUploads<T>(input: {
|
||||
requestId: string
|
||||
ownerUserId: string
|
||||
uploadIds: string[]
|
||||
commit: (files: Map<string, TaskDetailStagedFile>) => Promise<T>
|
||||
}): Promise<T> {
|
||||
const uploadIds = Array.from(new Set(
|
||||
input.uploadIds.map((value) => String(value || '').trim()).filter(isSafeUploadId),
|
||||
))
|
||||
if (uploadIds.length !== input.uploadIds.length) {
|
||||
throw new Error('存在无效或重复的上传会话,请重新选择图片')
|
||||
}
|
||||
|
||||
const requestDirectory = getRequestDirectory(input.requestId)
|
||||
await mkdir(getStagingRoot(), { recursive: true })
|
||||
const release = uploadIds.length > 0
|
||||
? await acquireTaskMaterialUploadSessionLocks(uploadIds)
|
||||
: null
|
||||
let committed = false
|
||||
try {
|
||||
release?.assertHeld()
|
||||
await mkdir(requestDirectory, { recursive: false })
|
||||
const staged = new Map<string, TaskDetailStagedFile>()
|
||||
|
||||
for (let index = 0; index < uploadIds.length; index += 1) {
|
||||
release?.assertHeld()
|
||||
const uploadId = uploadIds[index]
|
||||
const session = await getOwnedUploadSession(uploadId, input.ownerUserId)
|
||||
if (!session) throw new Error('图片上传会话已失效,请重新选择图片')
|
||||
const sourcePath = await getUploadedFile(uploadId)
|
||||
if (!sourcePath) throw new Error('已上传图片文件缺失,请重新选择图片')
|
||||
const sourceStats = await lstat(sourcePath)
|
||||
if (!sourceStats.isFile() || sourceStats.isSymbolicLink() || sourceStats.size <= 0) {
|
||||
throw new Error('已上传图片文件状态异常,请重新选择图片')
|
||||
}
|
||||
|
||||
const safeName = normalizeSafeFileName(session.fileName)
|
||||
if (!safeName) throw new Error('图片文件名无效')
|
||||
const storedName = `${String(index).padStart(3, '0')}-${safeName}`
|
||||
const targetPath = resolveStrictChildPath(requestDirectory, storedName)
|
||||
if (!targetPath) throw new Error('图片暂存路径无效')
|
||||
await copyFile(sourcePath, targetPath, fsConstants.COPYFILE_EXCL)
|
||||
const targetStats = await lstat(targetPath)
|
||||
if (!targetStats.isFile() || targetStats.isSymbolicLink() || targetStats.size !== sourceStats.size) {
|
||||
throw new Error('图片持久暂存校验失败')
|
||||
}
|
||||
staged.set(uploadId, {
|
||||
relativePath: path.posix.join(STAGING_FOLDER, input.requestId, storedName),
|
||||
fileName: safeName,
|
||||
fileSize: targetStats.size,
|
||||
})
|
||||
}
|
||||
|
||||
release?.assertHeld()
|
||||
const result = await input.commit(staged)
|
||||
committed = true
|
||||
await Promise.allSettled(uploadIds.map((uploadId) => cleanupConsumedUploadSession(uploadId)))
|
||||
return result
|
||||
} finally {
|
||||
try {
|
||||
if (release) await release()
|
||||
} finally {
|
||||
if (!committed) {
|
||||
await rm(requestDirectory, { recursive: true, force: true }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveTaskDetailStagedFile(
|
||||
requestId: string,
|
||||
file: TaskDetailStagedFile,
|
||||
): { trusted: true; filePath: string; fileName: string; fileSize: number } {
|
||||
const requestDirectory = getRequestDirectory(requestId)
|
||||
const expectedPrefix = `${STAGING_FOLDER}/${requestId}/`
|
||||
const normalized = String(file?.relativePath || '').replace(/\\/g, '/')
|
||||
if (!normalized.startsWith(expectedPrefix)) {
|
||||
throw new Error('图片审批暂存引用超出申请范围')
|
||||
}
|
||||
const baseName = normalized.slice(expectedPrefix.length)
|
||||
const filePath = resolveStrictChildPath(requestDirectory, baseName)
|
||||
if (!filePath) throw new Error('图片审批暂存引用无效')
|
||||
const fileName = normalizeSafeFileName(file.fileName)
|
||||
if (!fileName) throw new Error('图片审批暂存文件名无效')
|
||||
return {
|
||||
trusted: true,
|
||||
filePath,
|
||||
fileName,
|
||||
fileSize: Number(file.fileSize),
|
||||
}
|
||||
}
|
||||
|
||||
export async function cleanupTaskDetailChangeStaging(requestId: string) {
|
||||
await rm(getRequestDirectory(requestId), { recursive: true, force: true })
|
||||
}
|
||||
@ -0,0 +1,83 @@
|
||||
CREATE TABLE "task_detail_change_requests" (
|
||||
"id" TEXT NOT NULL,
|
||||
"material_id" TEXT NOT NULL,
|
||||
"requester_user_id" TEXT NOT NULL,
|
||||
"requester_employee_id" TEXT NOT NULL,
|
||||
"requester_name" TEXT NOT NULL,
|
||||
"requester_snapshot" JSONB NOT NULL,
|
||||
"requester_position_level" TEXT,
|
||||
"requester_level_type" TEXT,
|
||||
"requester_is_leadership" BOOLEAN NOT NULL DEFAULT false,
|
||||
"manager_employee_id" TEXT NOT NULL,
|
||||
"manager_user_id" TEXT,
|
||||
"manager_dingtalk_user_id" TEXT NOT NULL,
|
||||
"manager_name" TEXT NOT NULL,
|
||||
"manager_snapshot" JSONB NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending_manager_approval',
|
||||
"operations" JSONB NOT NULL,
|
||||
"base_material_updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"submitted_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"decided_at" TIMESTAMP(3),
|
||||
"decided_by_user_id" TEXT,
|
||||
"decision_comment" TEXT,
|
||||
"applied_at" TIMESTAMP(3),
|
||||
"apply_error" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "task_detail_change_requests_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "task_detail_change_requests_status_check" CHECK ("status" IN (
|
||||
'pending_manager_approval', 'applying', 'applied', 'rejected', 'expired', 'apply_failed', 'cancelled'
|
||||
)),
|
||||
CONSTRAINT "task_detail_change_requests_material_fkey" FOREIGN KEY ("material_id") REFERENCES "materials"("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "task_detail_change_requests_requester_user_fkey" FOREIGN KEY ("requester_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "task_detail_change_requests_manager_user_fkey" FOREIGN KEY ("manager_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT "task_detail_change_requests_requester_employee_fkey" FOREIGN KEY ("requester_employee_id") REFERENCES "performance_survey_employee_profiles"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "task_detail_change_requests_manager_employee_fkey" FOREIGN KEY ("manager_employee_id") REFERENCES "performance_survey_employee_profiles"("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX "task_detail_change_requests_material_created_idx"
|
||||
ON "task_detail_change_requests"("material_id", "created_at");
|
||||
CREATE INDEX "task_detail_change_requests_requester_created_idx"
|
||||
ON "task_detail_change_requests"("requester_user_id", "created_at");
|
||||
CREATE INDEX "task_detail_change_requests_manager_status_idx"
|
||||
ON "task_detail_change_requests"("manager_dingtalk_user_id", "status");
|
||||
CREATE INDEX "task_detail_change_requests_status_expires_idx"
|
||||
ON "task_detail_change_requests"("status", "expires_at");
|
||||
CREATE INDEX "task_detail_change_requests_status_updated_idx"
|
||||
ON "task_detail_change_requests"("status", "updated_at");
|
||||
|
||||
CREATE TABLE "task_detail_change_notifications" (
|
||||
"id" TEXT NOT NULL,
|
||||
"request_id" TEXT NOT NULL,
|
||||
"recipient_user_id" TEXT,
|
||||
"dingtalk_user_id" TEXT NOT NULL,
|
||||
"recipient_name" TEXT NOT NULL,
|
||||
"event_type" TEXT NOT NULL,
|
||||
"business_key" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"action_url" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"attempt_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"max_attempts" INTEGER NOT NULL DEFAULT 5,
|
||||
"next_retry_at" TIMESTAMP(3),
|
||||
"provider_message_id" TEXT,
|
||||
"last_error" TEXT,
|
||||
"sent_at" TIMESTAMP(3),
|
||||
"completed_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "task_detail_change_notifications_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "task_detail_change_notifications_status_check" CHECK ("status" IN (
|
||||
'pending', 'processing', 'retrying', 'succeeded', 'exhausted', 'skipped'
|
||||
)),
|
||||
CONSTRAINT "task_detail_change_notifications_request_fkey" FOREIGN KEY ("request_id") REFERENCES "task_detail_change_requests"("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "task_detail_change_notifications_event_key"
|
||||
ON "task_detail_change_notifications"("event_type", "business_key");
|
||||
CREATE INDEX "task_detail_change_notifications_request_created_idx"
|
||||
ON "task_detail_change_notifications"("request_id", "created_at");
|
||||
CREATE INDEX "task_detail_change_notifications_status_retry_idx"
|
||||
ON "task_detail_change_notifications"("status", "next_retry_at");
|
||||
@ -56,6 +56,8 @@ model User {
|
||||
transferToDept Department? @relation("UserTransferDepartment", fields: [transferToDeptId], references: [id])
|
||||
employeeBindings UserEmployeeBinding[]
|
||||
permissionInherits UserPermissionInherit[]
|
||||
submittedTaskDetailChangeRequests TaskDetailChangeRequest[] @relation("TaskDetailChangeRequesterUser")
|
||||
managedTaskDetailChangeRequests TaskDetailChangeRequest[] @relation("TaskDetailChangeManagerUser")
|
||||
|
||||
@@index([status])
|
||||
@@index([archivedAt])
|
||||
@ -128,6 +130,7 @@ model Material {
|
||||
transfers MaterialTransfer[]
|
||||
transcodeProgresses VideoTranscodeProgress[] @relation("MaterialTranscodeProgress")
|
||||
productPackageUploadJobs ProductPackageUploadJob[]
|
||||
taskDetailChangeRequests TaskDetailChangeRequest[]
|
||||
user User @relation("MaterialOwner", fields: [userId], references: [id], onDelete: Cascade)
|
||||
taskDetailRequester User? @relation("TaskDetailRequester", fields: [taskDetailRequesterUserId], references: [id], onDelete: SetNull)
|
||||
custodyDepartment Department? @relation("MaterialCustodyDepartment", fields: [custodyDepartmentId], references: [id], onDelete: SetNull)
|
||||
@ -650,6 +653,8 @@ model PerformanceSurveyEmployeeProfile {
|
||||
managedWorkReviewProjects WorkReviewProject[] @relation("WorkReviewManager")
|
||||
finalWorkReviewProjects WorkReviewProject[] @relation("WorkReviewFinalReviewer")
|
||||
workReviews WorkReviewReview[] @relation("WorkReviewReviewer")
|
||||
submittedTaskDetailChangeRequests TaskDetailChangeRequest[] @relation("TaskDetailChangeRequesterEmployee")
|
||||
managedTaskDetailChangeRequests TaskDetailChangeRequest[] @relation("TaskDetailChangeManagerEmployee")
|
||||
|
||||
@@index([displayName])
|
||||
@@index([dingEmail])
|
||||
@ -1279,6 +1284,79 @@ model WorkReviewNotification {
|
||||
@@map("work_review_notifications")
|
||||
}
|
||||
|
||||
/// 任务详情图片变更申请。正式素材只在直属主管批准后才应用。
|
||||
model TaskDetailChangeRequest {
|
||||
id String @id @default(uuid())
|
||||
materialId String @map("material_id")
|
||||
requesterUserId String @map("requester_user_id")
|
||||
requesterEmployeeId String @map("requester_employee_id")
|
||||
requesterName String @map("requester_name")
|
||||
requesterSnapshot Json @map("requester_snapshot")
|
||||
requesterPositionLevel String? @map("requester_position_level")
|
||||
requesterLevelType String? @map("requester_level_type")
|
||||
requesterIsLeadership Boolean @default(false) @map("requester_is_leadership")
|
||||
managerEmployeeId String @map("manager_employee_id")
|
||||
managerUserId String? @map("manager_user_id")
|
||||
managerDingtalkUserId String @map("manager_dingtalk_user_id")
|
||||
managerName String @map("manager_name")
|
||||
managerSnapshot Json @map("manager_snapshot")
|
||||
status String @default("pending_manager_approval")
|
||||
operations Json
|
||||
baseMaterialUpdatedAt DateTime @map("base_material_updated_at")
|
||||
submittedAt DateTime @default(now()) @map("submitted_at")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
decidedAt DateTime? @map("decided_at")
|
||||
decidedByUserId String? @map("decided_by_user_id")
|
||||
decisionComment String? @map("decision_comment")
|
||||
appliedAt DateTime? @map("applied_at")
|
||||
applyError String? @map("apply_error")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
material Material @relation(fields: [materialId], references: [id], onDelete: Cascade)
|
||||
requesterUser User @relation("TaskDetailChangeRequesterUser", fields: [requesterUserId], references: [id], onDelete: Restrict)
|
||||
managerUser User? @relation("TaskDetailChangeManagerUser", fields: [managerUserId], references: [id], onDelete: SetNull)
|
||||
requesterEmployee PerformanceSurveyEmployeeProfile @relation("TaskDetailChangeRequesterEmployee", fields: [requesterEmployeeId], references: [id], onDelete: Restrict)
|
||||
managerEmployee PerformanceSurveyEmployeeProfile @relation("TaskDetailChangeManagerEmployee", fields: [managerEmployeeId], references: [id], onDelete: Restrict)
|
||||
notifications TaskDetailChangeNotification[]
|
||||
|
||||
@@index([materialId, createdAt], map: "task_detail_change_requests_material_created_idx")
|
||||
@@index([requesterUserId, createdAt], map: "task_detail_change_requests_requester_created_idx")
|
||||
@@index([managerDingtalkUserId, status], map: "task_detail_change_requests_manager_status_idx")
|
||||
@@index([status, expiresAt], map: "task_detail_change_requests_status_expires_idx")
|
||||
@@index([status, updatedAt], map: "task_detail_change_requests_status_updated_idx")
|
||||
@@map("task_detail_change_requests")
|
||||
}
|
||||
|
||||
/// 任务详情变更审批通知 outbox;投递失败可恢复,不影响审批主状态。
|
||||
model TaskDetailChangeNotification {
|
||||
id String @id @default(uuid())
|
||||
requestId String @map("request_id")
|
||||
recipientUserId String? @map("recipient_user_id")
|
||||
dingtalkUserId String @map("dingtalk_user_id")
|
||||
recipientName String @map("recipient_name")
|
||||
eventType String @map("event_type")
|
||||
businessKey String @map("business_key")
|
||||
title String
|
||||
content String
|
||||
actionUrl String @map("action_url")
|
||||
status String @default("pending")
|
||||
attemptCount Int @default(0) @map("attempt_count")
|
||||
maxAttempts Int @default(5) @map("max_attempts")
|
||||
nextRetryAt DateTime? @map("next_retry_at")
|
||||
providerMessageId String? @map("provider_message_id")
|
||||
lastError String? @map("last_error")
|
||||
sentAt DateTime? @map("sent_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
request TaskDetailChangeRequest @relation(fields: [requestId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([eventType, businessKey], map: "task_detail_change_notifications_event_key")
|
||||
@@index([requestId, createdAt], map: "task_detail_change_notifications_request_created_idx")
|
||||
@@index([status, nextRetryAt], map: "task_detail_change_notifications_status_retry_idx")
|
||||
@@map("task_detail_change_notifications")
|
||||
}
|
||||
|
||||
|
||||
/// 钉钉审批流程的本地只读快照。
|
||||
///
|
||||
|
||||
@ -1507,6 +1507,7 @@ const expectedMigrationNames = new Set([
|
||||
'20260827_add_ding_workflow_detail_fields',
|
||||
'20260826_add_pack_video_early_access',
|
||||
'20260828_add_pack_item_can_edit_assets',
|
||||
'20260828_add_task_detail_change_approvals',
|
||||
]);
|
||||
if (migrations.length !== expectedMigrationNames.size
|
||||
|| migrations.some((item) => !expectedMigrationNames.has(item && item.name)
|
||||
|
||||
@ -23,6 +23,7 @@ const TARGET_MIGRATIONS = Object.freeze([
|
||||
'20260827_add_ding_workflow_detail_fields',
|
||||
'20260826_add_pack_video_early_access',
|
||||
'20260828_add_pack_item_can_edit_assets',
|
||||
'20260828_add_task_detail_change_approvals',
|
||||
])
|
||||
const VERIFIED_BACKUP_GENERATOR = 'scripts/create-verified-production-backup.mjs'
|
||||
const VERIFIED_BACKUP_MAINTENANCE_CONFIRMATION = 'NO_WRITES_DURING_SNAPSHOT'
|
||||
@ -265,7 +266,9 @@ async function queryEvidence(prisma, localMigrations) {
|
||||
OR (table_name = 'performance_survey_employee_profiles' AND column_name IN ('employment_state', 'resigned_at', 'handover_dingtalk_user_id'))
|
||||
OR (table_name = 'user_employee_bindings' AND column_name IN ('id', 'user_id', 'employee_profile_id', 'binding_source', 'status'))
|
||||
OR (table_name = 'ding_workflow_snapshots' AND column_name IN ('process_instance_id', 'status', 'is_ongoing', 'source_updated_at_raw', 'scheme_field_count', 'task_count', 'active_task_count', 'synced_at', 'brand', 'demand_type', 'requester_name', 'requester_department', 'project_type', 'active_tasks', 'form_fields'))
|
||||
OR (table_name = 'ding_workflow_sync_states' AND column_name IN ('id', 'status', 'source_cursor_raw', 'last_completed_at', 'lease_owner', 'lease_expires_at', 'current_ongoing_count')))
|
||||
OR (table_name = 'ding_workflow_sync_states' AND column_name IN ('id', 'status', 'source_cursor_raw', 'last_completed_at', 'lease_owner', 'lease_expires_at', 'current_ongoing_count'))
|
||||
OR (table_name = 'task_detail_change_requests' AND column_name IN ('id', 'material_id', 'requester_user_id', 'manager_dingtalk_user_id', 'status', 'operations', 'base_material_updated_at', 'expires_at'))
|
||||
OR (table_name = 'task_detail_change_notifications' AND column_name IN ('id', 'request_id', 'event_type', 'business_key', 'status', 'next_retry_at')))
|
||||
ORDER BY table_name, column_name`,
|
||||
)
|
||||
const indexes = await prisma.$queryRawUnsafe(
|
||||
@ -298,7 +301,10 @@ async function queryEvidence(prisma, localMigrations) {
|
||||
'ding_workflow_snapshots_brand_idx',
|
||||
'ding_workflow_snapshots_demand_type_idx',
|
||||
'ding_workflow_snapshots_requester_department_idx',
|
||||
'ding_workflow_sync_states_lease_idx'
|
||||
'ding_workflow_sync_states_lease_idx',
|
||||
'task_detail_change_requests_status_expires_idx',
|
||||
'task_detail_change_requests_status_updated_idx',
|
||||
'task_detail_change_notifications_status_retry_idx'
|
||||
)
|
||||
ORDER BY idx.relname`,
|
||||
)
|
||||
@ -344,6 +350,20 @@ export function assertTargetMigrationEvidence(localMigrations, evidence) {
|
||||
requireColumn('materials', 'transcode_lease_token', 'text', 'YES')
|
||||
requireColumn('materials', 'transcode_lease_expires_at', 'timestamp without time zone', 'YES')
|
||||
requireColumn('materials', 'uploadId', 'text', 'YES')
|
||||
requireColumn('task_detail_change_requests', 'id', 'text', 'NO')
|
||||
requireColumn('task_detail_change_requests', 'material_id', 'text', 'NO')
|
||||
requireColumn('task_detail_change_requests', 'requester_user_id', 'text', 'NO')
|
||||
requireColumn('task_detail_change_requests', 'manager_dingtalk_user_id', 'text', 'NO')
|
||||
requireColumn('task_detail_change_requests', 'status', 'text', 'NO')
|
||||
requireColumn('task_detail_change_requests', 'operations', 'jsonb', 'NO')
|
||||
requireColumn('task_detail_change_requests', 'base_material_updated_at', 'timestamp without time zone', 'NO')
|
||||
requireColumn('task_detail_change_requests', 'expires_at', 'timestamp without time zone', 'NO')
|
||||
requireColumn('task_detail_change_notifications', 'id', 'text', 'NO')
|
||||
requireColumn('task_detail_change_notifications', 'request_id', 'text', 'NO')
|
||||
requireColumn('task_detail_change_notifications', 'event_type', 'text', 'NO')
|
||||
requireColumn('task_detail_change_notifications', 'business_key', 'text', 'NO')
|
||||
requireColumn('task_detail_change_notifications', 'status', 'text', 'NO')
|
||||
requireColumn('task_detail_change_notifications', 'next_retry_at', 'timestamp without time zone', 'YES')
|
||||
requireColumn('materials', 'custody_department_id', 'text', 'YES')
|
||||
requireColumn('materials', 'custody_at', 'timestamp without time zone', 'YES')
|
||||
requireColumn('materials', 'custody_reason', 'text', 'YES')
|
||||
@ -450,6 +470,21 @@ export function assertTargetMigrationEvidence(localMigrations, evidence) {
|
||||
'ding_workflow_sync_states',
|
||||
['lease_expires_at'],
|
||||
)
|
||||
assertExactBtreeIndex(
|
||||
indexByName.get('task_detail_change_requests_status_expires_idx'),
|
||||
'task_detail_change_requests',
|
||||
['status', 'expires_at'],
|
||||
)
|
||||
assertExactBtreeIndex(
|
||||
indexByName.get('task_detail_change_requests_status_updated_idx'),
|
||||
'task_detail_change_requests',
|
||||
['status', 'updated_at'],
|
||||
)
|
||||
assertExactBtreeIndex(
|
||||
indexByName.get('task_detail_change_notifications_status_retry_idx'),
|
||||
'task_detail_change_notifications',
|
||||
['status', 'next_retry_at'],
|
||||
)
|
||||
|
||||
const isTrustedBrandValue = (value, localDefault) => {
|
||||
const normalized = String(value || '').trim()
|
||||
|
||||
@ -339,6 +339,7 @@ test('target evidence rejects nullable logo columns and structurally wrong index
|
||||
['20260827_add_ding_workflow_detail_fields', { checksum: 'l' }],
|
||||
['20260826_add_pack_video_early_access', { checksum: 'm' }],
|
||||
['20260828_add_pack_item_can_edit_assets', { checksum: 'n' }],
|
||||
['20260828_add_task_detail_change_approvals', { checksum: 'o' }],
|
||||
])
|
||||
const evidence = {
|
||||
migrationRows: [
|
||||
@ -356,6 +357,7 @@ test('target evidence rejects nullable logo columns and structurally wrong index
|
||||
{ migration_name: '20260827_add_ding_workflow_detail_fields', checksum: 'l', finished_at: 'now', rolled_back_at: null },
|
||||
{ migration_name: '20260826_add_pack_video_early_access', checksum: 'm', finished_at: 'now', rolled_back_at: null },
|
||||
{ migration_name: '20260828_add_pack_item_can_edit_assets', checksum: 'n', finished_at: 'now', rolled_back_at: null },
|
||||
{ migration_name: '20260828_add_task_detail_change_approvals', checksum: 'o', finished_at: 'now', rolled_back_at: null },
|
||||
],
|
||||
columns: [
|
||||
{ table_name: 'materials', column_name: 'transcode_lease_token', data_type: 'text', is_nullable: 'YES' },
|
||||
@ -404,6 +406,20 @@ test('target evidence rejects nullable logo columns and structurally wrong index
|
||||
{ table_name: 'ding_workflow_sync_states', column_name: 'lease_owner', data_type: 'text', is_nullable: 'YES' },
|
||||
{ table_name: 'ding_workflow_sync_states', column_name: 'lease_expires_at', data_type: 'timestamp without time zone', is_nullable: 'YES' },
|
||||
{ table_name: 'ding_workflow_sync_states', column_name: 'current_ongoing_count', data_type: 'integer', is_nullable: 'NO' },
|
||||
{ table_name: 'task_detail_change_requests', column_name: 'id', data_type: 'text', is_nullable: 'NO' },
|
||||
{ table_name: 'task_detail_change_requests', column_name: 'material_id', data_type: 'text', is_nullable: 'NO' },
|
||||
{ table_name: 'task_detail_change_requests', column_name: 'requester_user_id', data_type: 'text', is_nullable: 'NO' },
|
||||
{ table_name: 'task_detail_change_requests', column_name: 'manager_dingtalk_user_id', data_type: 'text', is_nullable: 'NO' },
|
||||
{ table_name: 'task_detail_change_requests', column_name: 'status', data_type: 'text', is_nullable: 'NO' },
|
||||
{ table_name: 'task_detail_change_requests', column_name: 'operations', data_type: 'jsonb', is_nullable: 'NO' },
|
||||
{ table_name: 'task_detail_change_requests', column_name: 'base_material_updated_at', data_type: 'timestamp without time zone', is_nullable: 'NO' },
|
||||
{ table_name: 'task_detail_change_requests', column_name: 'expires_at', data_type: 'timestamp without time zone', is_nullable: 'NO' },
|
||||
{ table_name: 'task_detail_change_notifications', column_name: 'id', data_type: 'text', is_nullable: 'NO' },
|
||||
{ table_name: 'task_detail_change_notifications', column_name: 'request_id', data_type: 'text', is_nullable: 'NO' },
|
||||
{ table_name: 'task_detail_change_notifications', column_name: 'event_type', data_type: 'text', is_nullable: 'NO' },
|
||||
{ table_name: 'task_detail_change_notifications', column_name: 'business_key', data_type: 'text', is_nullable: 'NO' },
|
||||
{ table_name: 'task_detail_change_notifications', column_name: 'status', data_type: 'text', is_nullable: 'NO' },
|
||||
{ table_name: 'task_detail_change_notifications', column_name: 'next_retry_at', data_type: 'timestamp without time zone', is_nullable: 'YES' },
|
||||
{ table_name: 'system_settings', column_name: 'headerLogoUrl', data_type: 'text', is_nullable: 'NO', column_default: "'/brand/header-logo.png'::text" },
|
||||
{ table_name: 'system_settings', column_name: 'loginLogoUrl', data_type: 'text', is_nullable: 'NO', column_default: "'/brand/login-logo.png'::text" },
|
||||
{ table_name: 'system_settings', column_name: 'logoUrl', data_type: 'text', is_nullable: 'NO', column_default: "'/brand/header-logo.png'::text" },
|
||||
@ -429,6 +445,9 @@ test('target evidence rejects nullable logo columns and structurally wrong index
|
||||
{ indexname: 'ding_workflow_snapshots_demand_type_idx', indexdef: 'CREATE INDEX ding_workflow_snapshots_demand_type_idx ON public.ding_workflow_snapshots USING btree (demand_type)' },
|
||||
{ indexname: 'ding_workflow_snapshots_requester_department_idx', indexdef: 'CREATE INDEX ding_workflow_snapshots_requester_department_idx ON public.ding_workflow_snapshots USING btree (requester_department)' },
|
||||
{ indexname: 'ding_workflow_sync_states_lease_idx', indexdef: 'CREATE INDEX ding_workflow_sync_states_lease_idx ON public.ding_workflow_sync_states USING btree (lease_expires_at)' },
|
||||
{ indexname: 'task_detail_change_requests_status_expires_idx', indexdef: 'CREATE INDEX task_detail_change_requests_status_expires_idx ON public.task_detail_change_requests USING btree (status, expires_at)' },
|
||||
{ indexname: 'task_detail_change_requests_status_updated_idx', indexdef: 'CREATE INDEX task_detail_change_requests_status_updated_idx ON public.task_detail_change_requests USING btree (status, updated_at)' },
|
||||
{ indexname: 'task_detail_change_notifications_status_retry_idx', indexdef: 'CREATE INDEX task_detail_change_notifications_status_retry_idx ON public.task_detail_change_notifications USING btree (status, next_retry_at)' },
|
||||
],
|
||||
brandRows: [
|
||||
{ id: 'local', headerLogoUrl: '/brand/header-logo.png', loginLogoUrl: '/brand/login-logo.png' },
|
||||
@ -572,6 +591,7 @@ test('container supply chain and migrator image are immutable while SOP keeps co
|
||||
'20260827_add_ding_workflow_detail_fields',
|
||||
'20260826_add_pack_video_early_access',
|
||||
'20260828_add_pack_item_can_edit_assets',
|
||||
'20260828_add_task_detail_change_approvals',
|
||||
]) {
|
||||
assert.match(dockerfile, new RegExp(migrationName))
|
||||
assert.match(migratorSource, new RegExp(`'${migrationName}'`))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user