perf: 创意资产库第一页加速,空壳改为权威标记

This commit is contained in:
chenpei 2026-08-17 11:39:40 +08:00
parent 656d4a2794
commit 8009c62868
25 changed files with 351 additions and 172 deletions

View File

@ -63,7 +63,8 @@ RUN test -s "/app/public/brand/header-logo.png" && \
test -f "/app/prisma/migrations/20260812_align_material_upload_idempotency_index/migration.sql" && \
test -f "/app/prisma/migrations/20260813_add_material_custody_department/migration.sql" && \
test -f "/app/prisma/migrations/20260813_add_organization_units_and_employment_state/migration.sql" && \
test -f "/app/prisma/migrations/20260813_add_user_employee_binding/migration.sql"
test -f "/app/prisma/migrations/20260813_add_user_employee_binding/migration.sql" && \
test -f "/app/prisma/migrations/20260817_add_material_is_incomplete_shell/migration.sql"
# 这两个动态路由负责兜底访问本地上传文件;如果它们在构建上下文里丢失,
# 线上音频/视频就会直接 404。这里提前失败,避免再把缺路由的镜像发出去。
@ -172,6 +173,7 @@ COPY --from=builder --chown=root:root /app/prisma/migrations/20260812_align_mate
COPY --from=builder --chown=root:root /app/prisma/migrations/20260813_add_material_custody_department ./prisma/migrations/20260813_add_material_custody_department
COPY --from=builder --chown=root:root /app/prisma/migrations/20260813_add_organization_units_and_employment_state ./prisma/migrations/20260813_add_organization_units_and_employment_state
COPY --from=builder --chown=root:root /app/prisma/migrations/20260813_add_user_employee_binding ./prisma/migrations/20260813_add_user_employee_binding
COPY --from=builder --chown=root:root /app/prisma/migrations/20260817_add_material_is_incomplete_shell ./prisma/migrations/20260817_add_material_is_incomplete_shell
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

View File

@ -297,30 +297,36 @@ export async function GET(request: NextRequest) {
if (empty) {
return NextResponse.json({
materials: [],
hasMore: false,
totalPages: 0,
total: 0,
totalCount: 0,
currentPage: page,
});
}
// 获取总数
let totalCount: number;
try {
totalCount = await prisma.material.count({ where });
} catch (e: any) {
if (e?.meta?.column?.includes('deletedAt')) {
console.warn('[Materials API] deletedAt field not found, retrying without deletedAt filter');
const whereWithoutDeleted = { ...where };
delete whereWithoutDeleted.deletedAt;
totalCount = await prisma.material.count({ where: whereWithoutDeleted });
} else {
throw e;
// 创意资产库首页用 deferTotal=1:先返回当前页,不阻塞全库 count。
// 后台管理等仍走原来的 total/totalPages。
const deferTotal = searchParams.get('deferTotal') === '1';
let totalCount: number | null = null;
let totalPages: number | null = null;
if (!deferTotal) {
try {
totalCount = await prisma.material.count({ where });
} catch (e: any) {
if (e?.meta?.column?.includes('deletedAt')) {
console.warn('[Materials API] deletedAt field not found, retrying without deletedAt filter');
const whereWithoutDeleted = { ...where };
delete whereWithoutDeleted.deletedAt;
totalCount = await prisma.material.count({ where: whereWithoutDeleted });
} else {
throw e;
}
}
totalPages = Math.ceil(totalCount / pageSize);
}
const totalPages = Math.ceil(totalCount / pageSize);
// 排序 - 使用 id 作为次要排序键,确保分页排序稳定(避免重复数据)
let orderBy: any = [{ createdAt: sortOrder }, { id: 'asc' }];
if (sortBy === 'downloadCount') {
@ -373,36 +379,13 @@ export async function GET(request: NextRequest) {
avatar: true,
},
},
// 包含 assets 数据(任务-详情需要)
assets: {
select: {
id: true,
assetType: true,
fileUrl: true,
fileName: true,
fileSize: true,
sortOrder: true,
createdAt: true,
updatedAt: true,
skuCode: true,
barcodes: {
select: {
barcode: true,
},
},
},
where: {
assetType: { in: ['main-preview', 'sku-preview', 'detail-preview', 'preview'] },
},
orderBy: { sortOrder: 'asc' },
},
},
};
if (sortBy !== 'comprehensive') {
query.orderBy = orderBy;
query.skip = (page - 1) * pageSize;
query.take = pageSize;
query.take = pageSize + (deferTotal ? 1 : 0);
} else {
// comprehensive排序:获取更多数据用于内存排序,但有上限
// 只需要前 (page * pageSize) 条数据,最多 MAX_QUERY_LIMIT 条
@ -640,6 +623,18 @@ export async function GET(request: NextRequest) {
throw e;
}
}
let hasMore = false;
if (deferTotal && sortBy !== 'comprehensive') {
hasMore = materials.length > pageSize;
if (hasMore) {
materials = materials.slice(0, pageSize);
}
} else if (totalPages != null) {
hasMore = page < totalPages;
} else {
hasMore = materials.length === pageSize;
}
// 批量查询点赞和收藏状态
const likedSet = new Set<string>();
@ -677,20 +672,22 @@ export async function GET(request: NextRequest) {
}
// 格式化返回数据
// 对于任务-详情类型,需要从 assets 中获取正确的预览图
// 任务-详情 / 缺封面的任务-页面:单独批量取封面,避免列表主查询联资产表
const productPackageMaterialIds = materials
.filter(m => isProductPackageType(m.materialType))
.map(m => m.id);
const taskPageMissingPreviewIds = materials
.filter(m => m.materialType === '任务-页面' && !m.previewUrl && !m.videoCoverUrl)
.map(m => m.id);
const coverMaterialIds = [...new Set([...productPackageMaterialIds, ...taskPageMissingPreviewIds])];
// 批量获取 Product Package 素材的封面图(优化:使用单次查询)
const coverUrlMap = new Map<string, string | null>();
if (productPackageMaterialIds.length > 0) {
if (coverMaterialIds.length > 0) {
try {
// 批量查询所有 Product Package 素材的主图和SKU图
const assets = await prisma.materialAsset.findMany({
where: {
materialId: { in: productPackageMaterialIds },
assetType: { in: ['main', 'sku', 'main-preview', 'sku-preview'] },
materialId: { in: coverMaterialIds },
assetType: { in: ['main', 'sku', 'main-preview', 'sku-preview', 'preview'] },
},
select: {
materialId: true,
@ -705,15 +702,13 @@ export async function GET(request: NextRequest) {
],
});
// 按 materialId 分组,优先使用预览图(main-preview/sku-preview),没有预览图则使用原图(main/sku)
const assetsByMaterial = new Map<string, { mainPreview?: string; skuPreview?: string; main?: string; sku?: string }>();
const assetsByMaterial = new Map<string, { mainPreview?: string; skuPreview?: string; main?: string; sku?: string; preview?: string }>();
for (const asset of assets) {
if (!assetsByMaterial.has(asset.materialId)) {
assetsByMaterial.set(asset.materialId, {});
}
const materialAssets = assetsByMaterial.get(asset.materialId)!;
// 分别记录预览图和原图
if (asset.assetType === 'main-preview') {
materialAssets.mainPreview = asset.fileUrl;
} else if (asset.assetType === 'sku-preview') {
@ -722,19 +717,19 @@ export async function GET(request: NextRequest) {
materialAssets.main = asset.fileUrl;
} else if (asset.assetType === 'sku') {
materialAssets.sku = asset.fileUrl;
} else if (asset.assetType === 'preview' && !materialAssets.preview) {
materialAssets.preview = asset.fileUrl;
}
}
// 构建封面图映射:优先使用预览图,没有预览图才使用原图
assetsByMaterial.forEach((materialAssets, materialId) => {
const coverUrl = materialAssets.mainPreview || materialAssets.skuPreview || materialAssets.main || materialAssets.sku || null;
const coverUrl = materialAssets.mainPreview || materialAssets.skuPreview || materialAssets.preview || materialAssets.main || materialAssets.sku || null;
if (coverUrl) {
coverUrlMap.set(materialId, coverUrl);
}
});
} catch (error) {
console.error('[Materials API] Failed to batch get cover URLs:', error);
// 如果批量查询失败,回退到逐个查询
await Promise.all(
productPackageMaterialIds.map(async (materialId) => {
try {
@ -755,9 +750,9 @@ export async function GET(request: NextRequest) {
let previewUrl = material.previewUrl || material.videoCoverUrl || '';
if (!previewUrl && material.materialType === '任务-页面') {
const previewAsset = (material.assets || []).find((asset: any) => asset.assetType === 'preview');
if (previewAsset) {
previewUrl = previewAsset.fileUrl;
const coverUrl = coverUrlMap.get(material.id);
if (coverUrl) {
previewUrl = coverUrl;
}
}
@ -855,10 +850,15 @@ export async function GET(request: NextRequest) {
return NextResponse.json({
materials: formattedMaterials,
totalPages,
total: totalCount, // 添加total字段以兼容前端
totalCount,
hasMore,
currentPage: page,
...(totalCount != null
? {
total: totalCount,
totalCount,
totalPages,
}
: {}),
});
} catch (error) {
if (error instanceof MaterialQueryError) {

View File

@ -4,6 +4,7 @@ import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { validateTaskNo } from '@/lib/product-package';
import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no';
import { INCOMPLETE_SHELL_CREATE_DATA } from '@/lib/material-query';
// POST - 初始化3D原图素材记录
export async function POST(request: NextRequest) {
@ -80,6 +81,7 @@ export async function POST(request: NextRequest) {
zipFilePath: '', // 临时空值,完成上传时更新
zipFileName: '', // 临时空值,完成上传时更新
taskStatus: 'uploading',
...INCOMPLETE_SHELL_CREATE_DATA,
renderer: '', // 3D原图不需要渲染器
},
});

View File

@ -4,6 +4,7 @@ import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { validateTaskNo } from '@/lib/product-package';
import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no';
import { INCOMPLETE_SHELL_CREATE_DATA } from '@/lib/material-query';
// POST - 初始化AI原图素材记录
export async function POST(request: NextRequest) {
@ -84,6 +85,7 @@ export async function POST(request: NextRequest) {
zipFilePath: '', // 临时空值,完成上传时更新
zipFileName: '', // 临时空值,完成上传时更新
taskStatus: 'uploading',
...INCOMPLETE_SHELL_CREATE_DATA,
renderer: '', // AI原图不需要渲染器
},
});

View File

@ -199,6 +199,7 @@ describe('certificate report complete data safety', () => {
zipFileName: 'report.jpg',
zipFileSize: BigInt(4),
taskStatus: 'completed',
isIncompleteShell: false,
}),
}))
expect(cleanupTempFiles).toHaveBeenCalledWith(

View File

@ -23,6 +23,7 @@ import { createMaterialLog } from '@/lib/material-log'
import { deleteFromS3 } from '@/lib/s3'
import { generatePdfPreview } from '@/lib/pdf-processor'
import { inspectBoundedRegularFile } from '@/lib/bounded-file-reader'
import { LIST_VISIBLE_MATERIAL_DATA } from '@/lib/material-query'
import { getOwnedUploadedFile } from '@/lib/upload-sessions'
import {
acquireTaskMaterialCompletionLock,
@ -446,6 +447,7 @@ export async function POST(request: NextRequest) {
zipFileName: firstFileName,
zipFileSize: BigInt(totalFileSize),
taskStatus: 'completed',
...LIST_VISIBLE_MATERIAL_DATA,
} as any,
})
if (finalizeResult.count !== 1) {

View File

@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { CERTIFICATE_REPORT_MATERIAL_TYPE, normalizeReportTitle } from '@/lib/certificate-report'
import { INCOMPLETE_SHELL_CREATE_DATA } from '@/lib/material-query'
export async function POST(request: NextRequest) {
try {
@ -50,6 +51,7 @@ export async function POST(request: NextRequest) {
zipFilePath: '',
zipFileName: '',
taskStatus: 'uploading',
...INCOMPLETE_SHELL_CREATE_DATA,
} as any,
})

View File

@ -10,6 +10,7 @@ import { prisma } from '@/lib/prisma';
import { validateTaskNo, isTaskNoExists } from '@/lib/product-package';
import { normalizeTaskDetailSourceType } from '@/lib/task-detail-source-type';
import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no';
import { INCOMPLETE_SHELL_CREATE_DATA } from '@/lib/material-query';
export async function POST(request: NextRequest) {
try {
@ -148,6 +149,7 @@ export async function POST(request: NextRequest) {
taskDetailSourceType,
taskDetailRequesterUserId: taskDetailRequester.id,
taskDetailRequesterName: taskDetailRequester.realName || taskDetailRequester.username,
...INCOMPLETE_SHELL_CREATE_DATA,
} as any,
});

View File

@ -4,6 +4,7 @@ import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { validateTaskNo } from '@/lib/product-package';
import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no';
import { INCOMPLETE_SHELL_CREATE_DATA } from '@/lib/material-query';
// POST - 初始化实拍原图素材记录
export async function POST(request: NextRequest) {
@ -84,6 +85,7 @@ export async function POST(request: NextRequest) {
zipFilePath: '', // 临时空值,完成上传时更新
zipFileName: '', // 临时空值,完成上传时更新
taskStatus: 'uploading',
...INCOMPLETE_SHELL_CREATE_DATA,
renderer: '', // 实拍原图不需要渲染器
},
});

View File

@ -4,6 +4,7 @@ import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { validateTaskNo } from '@/lib/product-package';
import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no';
import { INCOMPLETE_SHELL_CREATE_DATA } from '@/lib/material-query';
// POST - 初始化任务-页面素材记录
export async function POST(request: NextRequest) {
@ -85,6 +86,7 @@ export async function POST(request: NextRequest) {
zipFileName: '', // 临时空值,完成上传时更新
renderer: '', // 任务-页面不需要渲染器
taskStatus: 'uploading',
...INCOMPLETE_SHELL_CREATE_DATA,
},
});

View File

@ -4,6 +4,7 @@ import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { validateTaskNo } from '@/lib/product-package';
import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no';
import { INCOMPLETE_SHELL_CREATE_DATA } from '@/lib/material-query';
// POST - 初始化视频截图素材记录
export async function POST(request: NextRequest) {
@ -84,6 +85,7 @@ export async function POST(request: NextRequest) {
zipFilePath: '', // 临时空值,完成上传时更新
zipFileName: '', // 临时空值,完成上传时更新
taskStatus: 'uploading',
...INCOMPLETE_SHELL_CREATE_DATA,
renderer: '', // 视频截图不需要渲染器
},
});

View File

@ -104,6 +104,7 @@ export default function Home() {
const pathname = usePathname()
const [materials, setMaterials] = useState<Material[]>([])
const [loading, setLoading] = useState(true)
const [facetsLoading, setFacetsLoading] = useState(true)
const [showUploadModal, setShowUploadModal] = useState(false)
const [searchKeyword, setSearchKeyword] = useState('')
const [selectedCategory, setSelectedCategory] = useState('')
@ -205,6 +206,7 @@ export default function Home() {
if (includePaging) {
params.append('page', page.toString())
params.append('pageSize', pageSize.toString())
params.append('deferTotal', '1')
}
return params
@ -353,7 +355,7 @@ export default function Home() {
useEffect(() => {
if (typeof window === 'undefined') return
// 只在首页路径时执行
if (pathname !== '/') return
if (pathname !== '/materials') return
// 避免重复恢复
if (hasRestoredRef.current) return
@ -482,7 +484,7 @@ export default function Home() {
// 当从其他页面返回首页时,重置恢复标记
useEffect(() => {
if (pathname === '/') {
if (pathname === '/materials') {
// 延迟重置,确保恢复逻辑已执行
const timer = setTimeout(() => {
hasRestoredRef.current = false
@ -600,15 +602,23 @@ export default function Home() {
})
} else {
setMaterials(materialsList)
setDisplayedCount(Math.min(24, materialsList.length))
setDisplayedCount(materialsList.length)
}
const appendedCount = append ? appendedItems.length : materialsList.length
setCurrentPage(page)
const totalPages = data.totalPages || 1
setHasMore(appendedCount > 0 && page < totalPages)
setCurrentMaterialsTotalCount(data.total ?? data.totalCount ?? materialsList.length)
if (typeof data.hasMore === 'boolean') {
setHasMore(data.hasMore)
} else {
const totalPages = data.totalPages || 1
setHasMore(appendedCount > 0 && page < totalPages)
}
if (typeof data.total === 'number') {
setCurrentMaterialsTotalCount(data.total)
} else if (typeof data.totalCount === 'number') {
setCurrentMaterialsTotalCount(data.totalCount)
}
// 对需要转码的视频素材启动轮询
appendedItems.forEach((material, index) => {
@ -947,7 +957,7 @@ export default function Home() {
// 如果当前显示的数量小于已加载的素材数量,直接增加显示数量
if (displayedCount < materials.length) {
setIsLoadingMore(true)
setDisplayedCount((prev) => Math.min(prev + 24, materials.length))
setDisplayedCount(materials.length)
setIsLoadingMore(false)
return
}
@ -959,7 +969,7 @@ export default function Home() {
setIsLoadingMore(true)
const appended = await fetchMaterials(currentPage + 1, true)
if (appended > 0) {
setDisplayedCount((prev) => Math.min(prev + 24, materials.length + appended))
setDisplayedCount((prev) => prev + appended)
}
setIsLoadingMore(false)
}, [isLoadingMore, displayedCount, materials.length, hasMore, currentPage, fetchMaterials])
@ -971,6 +981,7 @@ export default function Home() {
facetsAbortRef.current?.abort()
const controller = new AbortController()
facetsAbortRef.current = controller
setFacetsLoading(true)
try {
const params = buildFilterParams({ includeSort: false, includePaging: false })
@ -985,6 +996,9 @@ export default function Home() {
setProductCategories(data.productCategories || [])
setMonths(data.months || [])
setFacetTotals({ ...EMPTY_FACET_TOTALS, ...(data.totals || {}) })
if (typeof data.total === 'number') {
setCurrentMaterialsTotalCount(data.total)
}
setMaterialTypeCounts(
Object.fromEntries(
((data.materialTypes || []) as FacetItem[]).map((item) => [item.name, item.count]),
@ -998,6 +1012,9 @@ export default function Home() {
showToast(error instanceof Error ? error.message : '筛选数据加载失败', { variant: 'error' })
}
} finally {
if (requestId === facetsRequestIdRef.current) {
setFacetsLoading(false)
}
if (facetsAbortRef.current === controller) facetsAbortRef.current = null
}
}, [buildFilterParams, showToast])
@ -1268,22 +1285,14 @@ export default function Home() {
}
}
if (status === 'loading' || loading) {
return (
<div className="min-h-screen flex items-center justify-center dark:bg-gray-900">
<div className="text-xl text-gray-600 dark:text-gray-400">加载中...</div>
</div>
)
}
if (!session) {
if (status === 'unauthenticated') {
return null
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* 板块顶栏:品牌中心看板语言(含面包屑/淘宝搜索/内外网状态/性能监控/用户菜单),功能与旧顶栏等价 */}
<MaterialsHeader session={session} />
<MaterialsHeader session={session ?? null} />
{/* 维护通知横幅已在 Header 中显示 */}
{/* 收藏操作提示气泡 */}
@ -1349,6 +1358,7 @@ export default function Home() {
isFilterCollapsed={isFilterCollapsed}
setIsFilterCollapsed={setIsFilterCollapsed}
themeColor={themeColor}
facetsLoading={facetsLoading || status === 'loading'}
/>
{/* Active Filters Display */}

View File

@ -52,6 +52,7 @@ interface FilterPanelProps {
isFilterCollapsed: boolean
setIsFilterCollapsed: (value: boolean) => void
themeColor: string
facetsLoading?: boolean
}
function FilterPanel(props: FilterPanelProps) {
@ -66,6 +67,7 @@ function FilterPanel(props: FilterPanelProps) {
materialTypes, categories, scenes, platforms, productCategories, months,
totalMaterialsCount, materialsCount, facetTotals,
isFilterCollapsed, setIsFilterCollapsed, themeColor,
facetsLoading = false,
} = props
const handleSortChange = useCallback((newSortBy: string) => {
@ -88,67 +90,71 @@ function FilterPanel(props: FilterPanelProps) {
const activeFilterCount = [selectedMaterialType, selectedCategory, selectedScene, selectedPlatform, selectedProductCategory, selectedMonth].filter(Boolean).length
const btnBase = 'px-4 py-2 rounded-full text-sm font-medium transition-all'
const btnActive = 'text-white'
const btnInactive = 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700'
const btnInactive = 'bg-white text-gray-700 border border-gray-300 hover:bg-gray-50'
const countText = (value: number) => (facetsLoading ? '…' : String(value))
return (
<>
<div className="mb-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium text-gray-700 dark:text-gray-300">类型分页</h2>
{selectedMaterialType && <span className="text-xs text-gray-500 dark:text-gray-400">{materialsCount} 个素材</span>}
<h2 className="text-sm font-medium text-gray-700">类型分页</h2>
{selectedMaterialType && <span className="text-xs text-gray-500">{facetsLoading ? '计数加载中' : `${materialsCount} 个素材`}</span>}
</div>
<div className="flex flex-wrap gap-2">
<button onClick={() => setSelectedMaterialType('')} className={`${btnBase} ${selectedMaterialType === '' ? btnActive : btnInactive}`} style={selectedMaterialType === '' ? { backgroundColor: themeColor } : {}}>全部<span className={`ml-1.5 text-xs ${selectedMaterialType === '' ? 'text-gray-300' : 'text-gray-500 dark:text-gray-400'}`}>({totals.materialType})</span></button>
<div className="flex flex-wrap gap-2" aria-busy={facetsLoading}>
<button onClick={() => setSelectedMaterialType('')} className={`${btnBase} ${selectedMaterialType === '' ? btnActive : btnInactive}`} style={selectedMaterialType === '' ? { backgroundColor: themeColor } : {}}>全部<span className={`ml-1.5 text-xs ${selectedMaterialType === '' ? 'text-gray-300' : 'text-gray-500'}`}>({countText(totals.materialType)})</span></button>
{materialTypes.map((type) => (
<button key={type.value} onClick={() => setSelectedMaterialType(type.value === selectedMaterialType ? '' : type.value)} className={`${btnBase} ${selectedMaterialType === type.value ? btnActive : btnInactive}`} style={selectedMaterialType === type.value ? { backgroundColor: themeColor } : {}}>{type.label}<span className={`ml-1.5 text-xs ${selectedMaterialType === type.value ? 'text-gray-300' : 'text-gray-500 dark:text-gray-400'}`}>({type.count || 0})</span></button>
<button key={type.value} onClick={() => setSelectedMaterialType(type.value === selectedMaterialType ? '' : type.value)} className={`${btnBase} ${selectedMaterialType === type.value ? btnActive : btnInactive}`} style={selectedMaterialType === type.value ? { backgroundColor: themeColor } : {}}>{type.label}<span className={`ml-1.5 text-xs ${selectedMaterialType === type.value ? 'text-gray-300' : 'text-gray-500'}`}>({countText(type.count || 0)})</span></button>
))}
{facetsLoading && materialTypes.length === 0 && [0, 1, 2, 3].map((item) => (
<span key={item} className="h-9 w-20 rounded-full bg-[#eceef0]" aria-hidden="true" />
))}
</div>
</div>
<div className="mb-4 border-t border-gray-200 dark:border-gray-700 pt-4">
<button onClick={() => setIsFilterCollapsed(!isFilterCollapsed)} className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors">
<div className="mb-4 border-t border-gray-200 pt-4">
<button onClick={() => setIsFilterCollapsed(!isFilterCollapsed)} className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors">
<svg className={`w-4 h-4 transition-transform duration-200 ${isFilterCollapsed ? '' : 'rotate-180'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
{isFilterCollapsed ? '展开更多筛选' : '收起筛选'}
{isFilterCollapsed && activeFilterCount > 0 && <span className="ml-1 px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 rounded text-xs">{activeFilterCount}</span>}
{isFilterCollapsed && activeFilterCount > 0 && <span className="ml-1 px-1.5 py-0.5 bg-blue-100 text-blue-600 rounded text-xs">{activeFilterCount}</span>}
</button>
</div>
<div className={`overflow-hidden transition-all duration-300 ${isFilterCollapsed ? 'max-h-0 opacity-0' : 'max-h-[2000px] opacity-100'}`}>
<div className="mb-4">
<div className="flex items-center justify-between mb-3"><h2 className="text-sm font-medium text-gray-700 dark:text-gray-300">使用店铺</h2>{selectedCategory && <span className="text-xs text-gray-500 dark:text-gray-400">{materialsCount} 个素材</span>}</div>
<div className="flex items-center justify-between mb-3"><h2 className="text-sm font-medium text-gray-700">使用店铺</h2>{selectedCategory && <span className="text-xs text-gray-500">{facetsLoading ? '计数加载中' : `${materialsCount} 个素材`}</span>}</div>
<div className="flex flex-wrap gap-2">
<button onClick={() => setSelectedCategory('')} className={`${btnBase} ${selectedCategory === '' ? btnActive : btnInactive}`} style={selectedCategory === '' ? { backgroundColor: themeColor } : {}}>全部<span className={`ml-1.5 text-xs ${selectedCategory === '' ? 'text-gray-300' : 'text-gray-500 dark:text-gray-400'}`}>({totals.category})</span></button>
{categories.map((c) => <button key={c.id} onClick={() => setSelectedCategory(c.name === selectedCategory ? '' : c.name)} className={`${btnBase} ${selectedCategory === c.name ? btnActive : btnInactive}`} style={selectedCategory === c.name ? { backgroundColor: themeColor } : {}}>{c.name}<span className={`ml-1.5 text-xs ${selectedCategory === c.name ? 'text-gray-300' : 'text-gray-500 dark:text-gray-400'}`}>({c.count || 0})</span></button>)}
<button onClick={() => setSelectedCategory('')} className={`${btnBase} ${selectedCategory === '' ? btnActive : btnInactive}`} style={selectedCategory === '' ? { backgroundColor: themeColor } : {}}>全部<span className={`ml-1.5 text-xs ${selectedCategory === '' ? 'text-gray-300' : 'text-gray-500'}`}>({countText(totals.category)})</span></button>
{categories.map((c) => <button key={c.id} onClick={() => setSelectedCategory(c.name === selectedCategory ? '' : c.name)} className={`${btnBase} ${selectedCategory === c.name ? btnActive : btnInactive}`} style={selectedCategory === c.name ? { backgroundColor: themeColor } : {}}>{c.name}<span className={`ml-1.5 text-xs ${selectedCategory === c.name ? 'text-gray-300' : 'text-gray-500'}`}>({countText(c.count || 0)})</span></button>)}
</div>
</div>
<div className="mb-4">
<div className="flex items-center justify-between mb-3"><h2 className="text-sm font-medium text-gray-700 dark:text-gray-300">场景类别</h2>{selectedScene && <span className="text-xs text-gray-500 dark:text-gray-400">{materialsCount} 个素材</span>}</div>
<div className="flex items-center justify-between mb-3"><h2 className="text-sm font-medium text-gray-700">场景类别</h2>{selectedScene && <span className="text-xs text-gray-500">{facetsLoading ? '计数加载中' : `${materialsCount} 个素材`}</span>}</div>
<div className="flex flex-wrap gap-2">
<button onClick={() => setSelectedScene('')} className={`${btnBase} ${selectedScene === '' ? btnActive : btnInactive}`} style={selectedScene === '' ? { backgroundColor: themeColor } : {}}>全部<span className={`ml-1.5 text-xs ${selectedScene === '' ? 'text-gray-300' : 'text-gray-500 dark:text-gray-400'}`}>({totals.scene})</span></button>
{scenes.map((s) => <button key={s.id} onClick={() => setSelectedScene(s.name === selectedScene ? '' : s.name)} className={`${btnBase} ${selectedScene === s.name ? btnActive : btnInactive}`} style={selectedScene === s.name ? { backgroundColor: themeColor } : {}}>{s.name}<span className={`ml-1.5 text-xs ${selectedScene === s.name ? 'text-gray-300' : 'text-gray-500 dark:text-gray-400'}`}>({s.count || 0})</span></button>)}
<button onClick={() => setSelectedScene('')} className={`${btnBase} ${selectedScene === '' ? btnActive : btnInactive}`} style={selectedScene === '' ? { backgroundColor: themeColor } : {}}>全部<span className={`ml-1.5 text-xs ${selectedScene === '' ? 'text-gray-300' : 'text-gray-500'}`}>({countText(totals.scene)})</span></button>
{scenes.map((s) => <button key={s.id} onClick={() => setSelectedScene(s.name === selectedScene ? '' : s.name)} className={`${btnBase} ${selectedScene === s.name ? btnActive : btnInactive}`} style={selectedScene === s.name ? { backgroundColor: themeColor } : {}}>{s.name}<span className={`ml-1.5 text-xs ${selectedScene === s.name ? 'text-gray-300' : 'text-gray-500'}`}>({countText(s.count || 0)})</span></button>)}
</div>
</div>
{platforms.length > 0 && <div className="mb-4">
<div className="flex items-center justify-between mb-3"><h2 className="text-sm font-medium text-gray-700 dark:text-gray-300">平台分类</h2>{selectedPlatform && <span className="text-xs text-gray-500 dark:text-gray-400">{materialsCount} 个素材</span>}</div>
{(platforms.length > 0 || facetsLoading) && <div className="mb-4">
<div className="flex items-center justify-between mb-3"><h2 className="text-sm font-medium text-gray-700">平台分类</h2>{selectedPlatform && <span className="text-xs text-gray-500">{facetsLoading ? '计数加载中' : `${materialsCount} 个素材`}</span>}</div>
<div className="flex flex-wrap gap-2">
<button onClick={() => setSelectedPlatform('')} className={`${btnBase} ${selectedPlatform === '' ? btnActive : btnInactive}`} style={selectedPlatform === '' ? { backgroundColor: themeColor } : {}}>全部<span className={`ml-1.5 text-xs ${selectedPlatform === '' ? 'text-gray-300' : 'text-gray-500 dark:text-gray-400'}`}>({totals.platform})</span></button>
{platforms.map((p) => <button key={p.id} onClick={() => setSelectedPlatform(p.name === selectedPlatform ? '' : p.name)} className={`${btnBase} ${selectedPlatform === p.name ? btnActive : btnInactive}`} style={selectedPlatform === p.name ? { backgroundColor: themeColor } : {}}>{p.name}<span className={`ml-1.5 text-xs ${selectedPlatform === p.name ? 'text-gray-300' : 'text-gray-500 dark:text-gray-400'}`}>({p.count || 0})</span></button>)}
<button onClick={() => setSelectedPlatform('')} className={`${btnBase} ${selectedPlatform === '' ? btnActive : btnInactive}`} style={selectedPlatform === '' ? { backgroundColor: themeColor } : {}}>全部<span className={`ml-1.5 text-xs ${selectedPlatform === '' ? 'text-gray-300' : 'text-gray-500'}`}>({countText(totals.platform)})</span></button>
{platforms.map((p) => <button key={p.id} onClick={() => setSelectedPlatform(p.name === selectedPlatform ? '' : p.name)} className={`${btnBase} ${selectedPlatform === p.name ? btnActive : btnInactive}`} style={selectedPlatform === p.name ? { backgroundColor: themeColor } : {}}>{p.name}<span className={`ml-1.5 text-xs ${selectedPlatform === p.name ? 'text-gray-300' : 'text-gray-500'}`}>({countText(p.count || 0)})</span></button>)}
</div>
</div>}
{productCategories.length > 0 && <div className="mb-4">
<div className="flex items-center justify-between mb-3"><h2 className="text-sm font-medium text-gray-700 dark:text-gray-300">产品类别</h2>{selectedProductCategory && <span className="text-xs text-gray-500 dark:text-gray-400">{materialsCount} 个素材</span>}</div>
{(productCategories.length > 0 || facetsLoading) && <div className="mb-4">
<div className="flex items-center justify-between mb-3"><h2 className="text-sm font-medium text-gray-700">产品类别</h2>{selectedProductCategory && <span className="text-xs text-gray-500">{facetsLoading ? '计数加载中' : `${materialsCount} 个素材`}</span>}</div>
<div className="flex flex-wrap gap-2">
<button onClick={() => setSelectedProductCategory('')} className={`${btnBase} ${selectedProductCategory === '' ? btnActive : btnInactive}`} style={selectedProductCategory === '' ? { backgroundColor: themeColor } : {}}>全部<span className={`ml-1.5 text-xs ${selectedProductCategory === '' ? 'text-gray-300' : 'text-gray-500 dark:text-gray-400'}`}>({totals.productCategory})</span></button>
{productCategories.map((c) => <button key={c.id} onClick={() => setSelectedProductCategory(c.name === selectedProductCategory ? '' : c.name)} className={`${btnBase} ${selectedProductCategory === c.name ? btnActive : btnInactive}`} style={selectedProductCategory === c.name ? { backgroundColor: themeColor } : {}}>{c.name}<span className={`ml-1.5 text-xs ${selectedProductCategory === c.name ? 'text-gray-300' : 'text-gray-500 dark:text-gray-400'}`}>({c.count || 0})</span></button>)}
<button onClick={() => setSelectedProductCategory('')} className={`${btnBase} ${selectedProductCategory === '' ? btnActive : btnInactive}`} style={selectedProductCategory === '' ? { backgroundColor: themeColor } : {}}>全部<span className={`ml-1.5 text-xs ${selectedProductCategory === '' ? 'text-gray-300' : 'text-gray-500'}`}>({countText(totals.productCategory)})</span></button>
{productCategories.map((c) => <button key={c.id} onClick={() => setSelectedProductCategory(c.name === selectedProductCategory ? '' : c.name)} className={`${btnBase} ${selectedProductCategory === c.name ? btnActive : btnInactive}`} style={selectedProductCategory === c.name ? { backgroundColor: themeColor } : {}}>{c.name}<span className={`ml-1.5 text-xs ${selectedProductCategory === c.name ? 'text-gray-300' : 'text-gray-500'}`}>({countText(c.count || 0)})</span></button>)}
</div>
</div>}
<div className="mb-4">
<div className="flex items-center justify-between mb-3"><h2 className="text-sm font-medium text-gray-700 dark:text-gray-300">上传月份</h2></div>
<div className="flex items-center justify-between mb-3"><h2 className="text-sm font-medium text-gray-700">上传月份</h2></div>
<div className="flex flex-wrap gap-2">
<button onClick={() => setSelectedMonth('')} className={`${btnBase} ${selectedMonth === '' ? btnActive : btnInactive}`} style={selectedMonth === '' ? { backgroundColor: themeColor } : {}}>全部<span className={`ml-1.5 text-xs ${selectedMonth === '' ? 'text-gray-300' : 'text-gray-500 dark:text-gray-400'}`}>({totals.month})</span></button>
{months.map((m) => <button key={m.value} onClick={() => setSelectedMonth(m.value === selectedMonth ? '' : m.value)} className={`${btnBase} ${selectedMonth === m.value ? btnActive : btnInactive}`} style={selectedMonth === m.value ? { backgroundColor: themeColor } : {}}>{m.label}<span className={`ml-1.5 text-xs ${selectedMonth === m.value ? 'text-gray-300' : 'text-gray-500 dark:text-gray-400'}`}>({m.count})</span></button>)}
<button onClick={() => setSelectedMonth('')} className={`${btnBase} ${selectedMonth === '' ? btnActive : btnInactive}`} style={selectedMonth === '' ? { backgroundColor: themeColor } : {}}>全部<span className={`ml-1.5 text-xs ${selectedMonth === '' ? 'text-gray-300' : 'text-gray-500'}`}>({countText(totals.month)})</span></button>
{months.map((m) => <button key={m.value} onClick={() => setSelectedMonth(m.value === selectedMonth ? '' : m.value)} className={`${btnBase} ${selectedMonth === m.value ? btnActive : btnInactive}`} style={selectedMonth === m.value ? { backgroundColor: themeColor } : {}}>{m.label}<span className={`ml-1.5 text-xs ${selectedMonth === m.value ? 'text-gray-300' : 'text-gray-500'}`}>({countText(m.count)})</span></button>)}
</div>
</div>
<div className="mb-4">
<div className="flex items-center justify-between mb-3"><h2 className="text-sm font-medium text-gray-700 dark:text-gray-300">排序方式</h2></div>
<div className="flex items-center justify-between mb-3"><h2 className="text-sm font-medium text-gray-700">排序方式</h2></div>
<div className="flex flex-wrap gap-2">
<button onClick={() => handleSortChange('createdAt')} className={`${btnBase} flex items-center gap-1 ${sortBy === 'createdAt' ? btnActive : btnInactive}`} style={sortBy === 'createdAt' ? { backgroundColor: themeColor } : {}}>
{sortBy === 'createdAt' && sortOrder === 'asc' ? '最早上传' : '最新上传'}
@ -159,7 +165,7 @@ function FilterPanel(props: FilterPanelProps) {
{sortBy === 'downloadCount' && <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortOrder === 'desc' ? 'M19 9l-7 7-7-7' : 'M5 15l7-7 7 7'} /></svg>}
</button>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-2">💡 提示:可以组合使用多个筛选条件,找到更精确的素材</div>
<div className="text-xs text-gray-500 mt-2">可以组合使用多个筛选条件,找到更精确的素材</div>
</div>
</div>
</>

View File

@ -1,8 +1,12 @@
'use client'
import React from 'react'
import React, { useRef } from 'react'
import gsap from 'gsap'
import { useGSAP } from '@gsap/react'
import MaterialCard, { Material } from './MaterialCard'
gsap.registerPlugin(useGSAP)
// 用户会话类型
interface UserSession {
id: string
@ -44,6 +48,59 @@ interface MaterialGridProps {
showToast: (message: string, options?: { variant?: 'success' | 'error' }) => void
}
function MaterialGridSkeleton() {
const rootRef = useRef<HTMLDivElement>(null)
useGSAP(() => {
if (typeof window.matchMedia !== 'function') return
const motion = gsap.matchMedia()
motion.add('(prefers-reduced-motion: no-preference)', () => {
gsap.fromTo(
'[data-skeleton-block]',
{ opacity: 0.42 },
{
opacity: 0.92,
duration: 1.15,
ease: 'sine.inOut',
yoyo: true,
repeat: -1,
stagger: { each: 0.08, from: 'start' },
},
)
})
return () => motion.revert()
}, { scope: rootRef })
return (
<div
ref={rootRef}
className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
aria-busy="true"
aria-live="polite"
aria-label="素材列表加载中"
>
{Array.from({ length: 8 }, (_, index) => (
<article
key={index}
className="overflow-hidden rounded-[14px] border border-[#e7e8ea] bg-white"
>
<div data-skeleton-block className="h-[400px] bg-[#eceef0]" />
<div className="space-y-3 px-4 py-4">
<div data-skeleton-block className="h-4 w-3/4 bg-[#eceef0]" />
<div data-skeleton-block className="h-3 w-1/2 bg-[#f2f3f5]" />
<div className="flex items-center justify-between pt-1">
<div data-skeleton-block className="h-3 w-20 bg-[#f2f3f5]" />
<div data-skeleton-block className="h-3 w-12 bg-[#f2f3f5]" />
</div>
</div>
</article>
))}
</div>
)
}
export default function MaterialGrid({
materials,
displayedCount,
@ -76,26 +133,21 @@ export default function MaterialGrid({
<div className="relative">
{/* 过渡加载遮罩 */}
{isTransitionLoading && (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-white/60 dark:bg-gray-900/60 backdrop-blur-[1px]">
<div className="w-6 h-6 border-2 border-gray-300 border-t-gray-900 dark:border-gray-700 dark:border-t-white rounded-full animate-spin" />
<div className="absolute inset-0 z-20 flex items-center justify-center bg-white/60 backdrop-blur-[1px]">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-[#e7e8ea] border-t-[#1a1c1d]" />
</div>
)}
{/* 加载状态 */}
{loading ? (
<div className="text-center py-16">
<p className="text-gray-500 dark:text-gray-400 text-lg">加载中...</p>
</div>
<MaterialGridSkeleton />
) : materials.length === 0 ? (
// 空状态
<div className="text-center py-16">
<p className="text-gray-500 dark:text-gray-400 text-lg">
<div className="py-16 text-center">
<p className="text-lg text-[#6b7075]">
{hasFilters ? '没有找到匹配的素材' : '还没有素材,快来上传第一个吧!'}
</p>
</div>
) : (
// 素材网格
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{materials.slice(0, displayedCount).map((material) => (
<MaterialCard
key={material.id}

View File

@ -1,7 +1,6 @@
import { prisma } from '@/lib/prisma'
import {
SPECIAL_INIT_MATERIAL_TYPES,
buildIncompleteSpecialInitShellWhere,
buildMaterialSearchOrConditions,
buildMaterialWhereFromSearchParams,
createMaterialQueryContext,
@ -126,7 +125,8 @@ describe('material search conditions', () => {
)
expect(result.empty).toBe(false)
expect(result.where.NOT).toEqual(buildIncompleteSpecialInitShellWhere())
expect(result.where.isIncompleteShell).toBe(false)
expect(result.where.NOT).toBeUndefined()
})
test.each(SPECIAL_INIT_MATERIAL_TYPES)(

View File

@ -11,7 +11,7 @@ import {
isSuperAdmin,
isSystemAdmin,
} from '@/lib/department-permission'
import { buildIncompleteSpecialInitShellWhere } from '@/lib/material-query'
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
@ -146,7 +146,6 @@ describe('materials route ai generated', () => {
realName: '张三',
avatar: null,
},
assets: [],
},
])
;(prisma.materialLike.findMany as jest.Mock).mockResolvedValue([])
@ -166,7 +165,7 @@ describe('materials route ai generated', () => {
expect(prisma.material.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
NOT: buildIncompleteSpecialInitShellWhere(),
isIncompleteShell: false,
}),
select: expect.objectContaining({
isAiGenerated: true,
@ -262,20 +261,14 @@ describe('materials route ai generated', () => {
realName: '张三',
avatar: null,
},
assets: [
{
id: 'preview-1',
assetType: 'preview',
fileUrl: 'https://example.com/task-page-preview.webp',
fileName: 'preview-0.webp',
fileSize: null,
sortOrder: 0,
createdAt: new Date('2026-06-26T08:00:00.000Z'),
updatedAt: new Date('2026-06-26T08:00:00.000Z'),
skuCode: null,
barcodes: [],
},
],
},
])
;(prisma.materialAsset.findMany as jest.Mock).mockResolvedValue([
{
materialId: 'task-page-1',
assetType: 'preview',
fileUrl: 'https://example.com/task-page-preview.webp',
sortOrder: 0,
},
])
@ -287,20 +280,78 @@ describe('materials route ai generated', () => {
expect(response.status).toBe(200)
expect(prisma.material.findMany).toHaveBeenCalledWith(
expect.objectContaining({
select: expect.objectContaining({
assets: expect.objectContaining({
where: {
assetType: {
in: ['main-preview', 'sku-preview', 'detail-preview', 'preview'],
},
},
}),
select: expect.not.objectContaining({
assets: expect.anything(),
}),
take: 20,
}),
)
expect(prisma.materialAsset.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
materialId: { in: ['task-page-1'] },
assetType: { in: ['main', 'sku', 'main-preview', 'sku-preview', 'preview'] },
}),
}),
)
expect(body.materials[0].previewUrl).toBe('https://example.com/task-page-preview.webp')
})
test('创意资产库 deferTotal 时不应先做全库 count,并用多取 1 条判断是否还有下一页', async () => {
const pageItems = Array.from({ length: 21 }, (_, index) => ({
id: `material-${index + 1}`,
userId: 'user-1',
previewUrl: `https://example.com/preview-${index + 1}.jpg`,
productModel: `SPU-${index + 1}`,
categoryName: '旗舰店',
sceneName: '客厅',
platformName: '天猫',
productCategoryName: '沙发',
materialType: '任务-详情',
renderer: '',
authorName: '张三',
zipFileName: 'sample.zip',
zipFileSize: BigInt(1024),
downloadCount: 0,
likeCount: 0,
createdAt: new Date('2026-03-25T08:00:00.000Z'),
videoSourceUrl: null,
displayImages: [],
videoPreview540Url: null,
videoPreviewClipUrl: null,
videoCoverUrl: null,
videoDuration: null,
videoProcessingStatus: null,
transcodeLastError: null,
transcodeAttempts: 0,
isAiGenerated: false,
taskDetailSourceType: null,
user: {
username: 'zhangsan',
realName: '张三',
avatar: null,
},
}))
;(prisma.material.findMany as jest.Mock).mockResolvedValue(pageItems)
const request = new NextRequest('http://localhost/api/materials?page=1&pageSize=20&sortBy=createdAt&deferTotal=1')
const response = await GET(request)
const body = await response.json()
expect(response.status).toBe(200)
expect(prisma.material.count).not.toHaveBeenCalled()
expect(prisma.material.findMany).toHaveBeenCalledWith(
expect.objectContaining({
skip: 0,
take: 21,
}),
)
expect(body.materials).toHaveLength(20)
expect(body.hasMore).toBe(true)
expect(body.totalCount).toBeUndefined()
expect(body.totalPages).toBeUndefined()
})
test('uploadId 状态查询仍应直接返回 uploading 空壳,不套用普通列表隐藏条件', async () => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
id: 'uploading-shell-1',

View File

@ -132,6 +132,7 @@ describe('product-package init route', () => {
// 作者名必须取自数据库中的用户名,而不是登录态里并不存在的字段,
// 更不能兜底成邮箱
authorName: 'tester',
isIncompleteShell: true,
}),
})
expect(prisma.user.findUnique).toHaveBeenCalledWith({

View File

@ -138,7 +138,7 @@ describe('任务号完成写入并发锁', () => {
expect(order).toEqual(['assets', 'completed'])
expect(tx.material.update).toHaveBeenCalledWith({
where: { id: 'material-1' },
data: { taskStatus: 'completed' },
data: { taskStatus: 'completed', isIncompleteShell: false },
})
})
@ -249,6 +249,7 @@ describe('任务号完成写入并发锁', () => {
where: { id: 'material-1' },
data: {
dingtalkApprovalNo: '202508211445000157693',
isIncompleteShell: false,
taskStatus: 'completed',
},
})

View File

@ -10,7 +10,6 @@ import {
isSuperAdmin,
isSystemAdmin,
} from '@/lib/department-permission'
import { buildIncompleteSpecialInitShellWhere } from '@/lib/material-query'
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
@ -71,7 +70,7 @@ describe('search suggestions special init visibility', () => {
for (const [query] of (prisma.material.findMany as jest.Mock).mock.calls) {
expect(query.where.AND[0]).toEqual(expect.objectContaining({
deletedAt: null,
NOT: buildIncompleteSpecialInitShellWhere(),
isIncompleteShell: false,
}))
}
})

View File

@ -20,6 +20,7 @@ describe.each(routeCases)('%s upload route safety', (routeName, materialType, fa
expect(initSource).toContain(`materialType: '${materialType}'`);
expect(initSource).toContain("taskStatus: 'uploading'");
expect(initSource).toContain('INCOMPLETE_SHELL_CREATE_DATA');
expect(completeSource).toContain('completeTaskImageMaterial({');
expect(completeSource).toContain(`materialType: '${materialType}'`);
expect(completeSource).toContain(`fallbackProductModel: '${fallbackProductModel}'`);

View File

@ -55,6 +55,7 @@ describe('任务-页面上传接口', () => {
data: expect.objectContaining({
materialType: '任务-页面',
taskStatus: 'uploading',
isIncompleteShell: true,
}),
}),
);

View File

@ -146,28 +146,16 @@ export function isProvablyIncompleteSpecialInitShell(
&& hasNonEmptyString(material.taskDetailRequesterUserId)
}
export const INCOMPLETE_SHELL_CREATE_DATA = {
isIncompleteShell: true,
} as const
export const LIST_VISIBLE_MATERIAL_DATA = {
isIncompleteShell: false,
} as const
export function buildIncompleteSpecialInitShellWhere(): Prisma.MaterialWhereInput {
return {
AND: [
{ materialType: { in: [...SPECIAL_INIT_MATERIAL_TYPES] } },
{ previewUrl: '' },
{ zipFilePath: '' },
{ zipFileName: '' },
{ displayImages: { isEmpty: true } },
{ assets: { none: {} } },
{
OR: [
{ taskStatus: { in: [...PROVABLY_INCOMPLETE_TASK_STATUSES] } },
{
materialType: '任务-详情',
taskStatus: null,
taskDetailSourceType: { in: [...TASK_DETAIL_SOURCE_TYPE_OPTIONS] },
taskDetailRequesterUserId: { not: null },
},
],
},
],
}
return { isIncompleteShell: true }
}
function unique(values: string[]): string[] {
@ -384,10 +372,9 @@ export async function buildMaterialWhereFromSearchParams(
where.deletedAt = { not: null }
} else {
where.deletedAt = null
// 专用上传会先创建 Material 再完成文件落库。统一查询层隐藏可证明未完成、
// 且完全没有文件证据的空壳,因此列表、筛选计数和搜索建议保持一致。
// 按 ID / uploadId 的状态查询不经过这里,上传重试和状态恢复仍可访问原记录。
where.NOT = buildIncompleteSpecialInitShellWhere()
// 列表只读上传时写下的权威标记,不再现场推断空壳。
// 按 ID / uploadId 的状态查询不经过这里,上传重试仍可访问原记录。
where.isIncompleteShell = false
}
const category = searchParams.get('category') || ''

View File

@ -9,6 +9,7 @@ import {
getDingtalkApprovalNoError,
normalizeDingtalkApprovalNo,
} from './dingtalk-approval-no';
import { LIST_VISIBLE_MATERIAL_DATA } from './material-query';
// 任务号格式:TASK + 12-18位数字 + 可选的多个子任务号(-2位数字,可重复)
// 示例:
@ -448,6 +449,7 @@ export async function finalizeTaskMaterialOnComplete(input: {
? { dingtalkApprovalNo: normalizedDingtalkApprovalNo }
: {}),
taskStatus: TASK_STATUS.COMPLETED,
...LIST_VISIBLE_MATERIAL_DATA,
} as any,
});
});
@ -509,6 +511,7 @@ export async function finalizeTaskMaterialOnComplete(input: {
? { dingtalkApprovalNo: normalizedDingtalkApprovalNo }
: {}),
taskStatus: TASK_STATUS.COMPLETED,
...LIST_VISIBLE_MATERIAL_DATA,
} as any,
});
});
@ -572,6 +575,7 @@ export async function createTaskMaterialOnComplete(input: {
...(normalizedTaskNo ? { taskNo: normalizedTaskNo } : {}),
dingtalkApprovalNo: normalizedDingtalkApprovalNo,
taskStatus: TASK_STATUS.COMPLETED,
...LIST_VISIBLE_MATERIAL_DATA,
} as any,
include: {
user: {

View File

@ -0,0 +1,43 @@
-- 素材列表可见性权威标记
--
-- 专用 init 会先建一条还没有文件的 Material。以前列表每次用
-- preview/zip/assets.none 现场判断空壳,打开创意资产库就会拖慢。
-- 现在把结论写在素材行上:init=true,complete=false,列表只读本列。
--
-- 可重复执行。默认 false,不影响已有可展示素材。
BEGIN;
ALTER TABLE "materials"
ADD COLUMN IF NOT EXISTS is_incomplete_shell BOOLEAN NOT NULL DEFAULT false;
CREATE INDEX IF NOT EXISTS materials_is_incomplete_shell_idx
ON "materials"(is_incomplete_shell);
UPDATE "materials"
SET is_incomplete_shell = true
WHERE "deletedAt" IS NULL
AND "materialType" IN (
'任务-3D原图',
'任务-AI原图',
'任务-实拍原图',
'任务-视频截图',
'任务-页面',
'证书-检测报告',
'任务-详情'
)
AND "previewUrl" = ''
AND "zipFilePath" = ''
AND "zipFileName" = ''
AND COALESCE(cardinality("displayImages"), 0) = 0
AND (
task_status IN ('uploading', 'processing', 'failed')
OR (
"materialType" = '任务-详情'
AND task_status IS NULL
AND task_detail_source_type IN ('实拍', '渲染', 'AI')
AND task_detail_requester_user_id IS NOT NULL
)
);
COMMIT;

View File

@ -115,6 +115,9 @@ model Material {
taskDetailSourceType String? @map("task_detail_source_type")
taskDetailRequesterUserId String? @map("task_detail_requester_user_id")
taskDetailRequesterName String? @map("task_detail_requester_name")
/// 专用 init 建档时写 true,完成落库后写 false。列表/筛选只读这个标记,
/// 不再每次去资产表现场判断是不是上传空壳。
isIncompleteShell Boolean @default(false) @map("is_incomplete_shell")
assets MaterialAsset[]
downloadLogs MaterialDownloadLog[]
favorites MaterialFavorite[]
@ -145,6 +148,7 @@ model Material {
@@index([taskNo])
@@index([dingtalkApprovalNo])
@@index([taskStatus])
@@index([isIncompleteShell])
@@index([isFullSet])
@@index([productModel])
@@index([reportTitle])