perf: facets 服务端缓存 + loadMonths 查询优化 + 首页去重复卡片

- facets API 增加内存缓存(TTL 5 分钟,键=角色+部门+筛选参数,容量 200 条)
- loadMonths 从 findMany 全表扫描改为 groupBy 按月聚合(数据库侧完成)
- 首页移除下方白卡区块(与编号索引重复)+ 恢复 05-07 规划中板块行
This commit is contained in:
chenpei 2026-08-17 09:09:51 +08:00
parent 17e23c26ae
commit 02df0b7ef4
2 changed files with 75 additions and 119 deletions

View File

@ -78,6 +78,40 @@ function toMaterialTypeItems(rows: any[]) {
}))
}
// ===== 服务端内存缓存 =====
// facets 的 7 条 SQL 在大库上可耗时 20 秒以上(尤其 loadMonths 的全表扫描)。
// 相同权限上下文 + 相同筛选参数的结果在 TTL 内直接返回缓存。
const FACETS_CACHE_TTL_MS = 5 * 60 * 1000 // 5 分钟
const facetsCache = new Map<string, { data: any; expiresAt: number }>()
function buildFacetsCacheKey(searchParams: URLSearchParams, user: any): string {
// 以角色+部门+筛选参数为缓存键:同权限的人共享同一份结果
const role = String(user?.role || 'USER')
const dept = String(user?.department || '')
const managed = String(user?.managedDepartment || '')
const filterKey = searchParams.toString()
return `${role}|${dept}|${managed}|${filterKey}`
}
function getFacetsFromCache(key: string): any | null {
const entry = facetsCache.get(key)
if (!entry) return null
if (Date.now() > entry.expiresAt) {
facetsCache.delete(key)
return null
}
return entry.data
}
function setFacetsCache(key: string, data: any) {
// 简单容量上限:最多保留 200 个不同筛选组合的缓存
if (facetsCache.size > 200) {
const oldest = facetsCache.keys().next().value
if (oldest) facetsCache.delete(oldest)
}
facetsCache.set(key, { data, expiresAt: Date.now() + FACETS_CACHE_TTL_MS })
}
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
@ -90,6 +124,14 @@ export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const user = session.user as any
// 缓存命中:直接返回,跳过全部 7 条 SQL
const cacheKey = buildFacetsCacheKey(searchParams, user)
const cached = getFacetsFromCache(cacheKey)
if (cached) {
return NextResponse.json(cached)
}
const context = await createMaterialQueryContext(searchParams, user)
const buildWhere = async (exclude?: MaterialFilterDimension) =>
@ -148,15 +190,36 @@ export async function GET(request: NextRequest) {
return { items: [], total: 0 }
}
const rows = await prisma.material.findMany({
// 用 groupBy 替代 findMany 全表扫描:只取按月聚合的计数,不拉全量 createdAt 行
const rows = await (prisma.material.groupBy as any)({
by: ['createdAt'],
where: result.where,
select: { createdAt: true },
orderBy: { createdAt: 'desc' },
_count: { _all: true },
})
// groupBy 返回去重后的 createdAt 及其计数,汇总为月度计数
const monthMap = new Map<string, number>()
let total = 0
for (const row of rows) {
const date = new Date(row.createdAt)
const value = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
const count = Number(row._count?._all || 0)
monthMap.set(value, (monthMap.get(value) || 0) + count)
total += count
}
return {
items: toMonthItems(rows),
total: rows.length,
items: Array.from(monthMap.entries())
.map(([value, count]) => {
const [, month] = value.split('-')
return {
value,
label: `${value.slice(0, 4)}年${Number.parseInt(month, 10)}月`,
count,
}
})
.sort((a, b) => b.value.localeCompare(a.value)),
total,
}
}
@ -174,7 +237,7 @@ export async function GET(request: NextRequest) {
])
const materialTypeResult = await loadMaterialTypes()
return NextResponse.json({
const responseData = {
total,
categories: categoryResult.items,
scenes: sceneResult.items,
@ -190,7 +253,12 @@ export async function GET(request: NextRequest) {
productCategory: productCategoryResult.total,
month: monthResult.total,
},
})
}
// 写入缓存,供同权限用户在 TTL 内复用
setFacetsCache(cacheKey, responseData)
return NextResponse.json(responseData)
} catch (error) {
if (error instanceof MaterialQueryError) {
return NextResponse.json(

View File

@ -147,7 +147,6 @@ export default function BoardHomePage() {
!pageRef.current?.querySelector('[data-hero-title]') ||
!pageRef.current.querySelector('[data-row-line]') ||
!pageRef.current.querySelector('[data-row]') ||
!pageRef.current.querySelector('[data-card]') ||
!pageRef.current.querySelector('[data-foot]')
) {
return
@ -190,20 +189,6 @@ export default function BoardHomePage() {
{ y: 0, autoAlpha: 1, duration: 0.55, stagger: 0.07, clearProps: 'opacity,visibility,transform' },
'-=0.5',
)
// 白卡:虚焦落位(与标题同语言)
.fromTo(
'[data-card]',
{ y: 20, autoAlpha: 0, filter: 'blur(8px)' },
{
y: 0,
autoAlpha: 1,
filter: 'blur(0px)',
duration: 0.75,
stagger: 0.12,
clearProps: 'opacity,visibility,transform,filter',
},
'-=0.4',
)
.fromTo(
'[data-foot]',
{ autoAlpha: 0 },
@ -326,103 +311,6 @@ export default function BoardHomePage() {
</section>
)}
{/* ===== 已上线板块的入口白卡 ===== */}
{sessionLoading ? (
<div className="grid gap-6 py-16 md:grid-cols-12 md:gap-8 md:py-24">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className={`h-[260px] animate-pulse rounded-xl border border-[#e7e8ea] bg-white/60 ${i % 2 === 0 ? 'md:col-span-7' : 'md:col-span-5'}`}
/>
))}
</div>
) : (
<section className="grid gap-6 py-16 md:grid-cols-12 md:gap-8 md:py-24">
<div data-card className="rounded-xl border border-[#e7e8ea] bg-white p-8 transition-all duration-[400ms] ease-[cubic-bezier(0.22,1,0.36,1)] hover:-translate-y-1.5 hover:border-[#c9ccd1] md:col-span-7 md:p-10">
<div className="flex items-center gap-2.5">
<StatusDot tone="live" />
<span className="text-[12px] text-[#6b7075]">运行中</span>
<span className={`font-en ml-auto text-[11px] uppercase tracking-[0.14em] ${labelCls}`}>01 / Assets</span>
</div>
<h3 className="mt-5 text-[22px] font-semibold tracking-tight">创意资产库</h3>
<p className="mt-3 text-[14px] leading-relaxed text-[#6b7075]">
素材浏览、上传、分享与下载——创意资产的全部日常都在这里。
</p>
<div className="mt-7">
<Link
href="/materials"
className={`inline-flex min-h-11 items-center rounded-lg bg-[#1a1c1d] px-6 text-[13px] font-medium text-white transition-all duration-200 hover:opacity-80 active:scale-[0.98] ${focusRing}`}
>
进入创意资产库
</Link>
</div>
</div>
<div data-card className="rounded-xl border border-[#e7e8ea] bg-white p-8 transition-all duration-[400ms] ease-[cubic-bezier(0.22,1,0.36,1)] hover:-translate-y-1.5 hover:border-[#c9ccd1] md:col-span-5 md:p-10">
<div className="flex items-center gap-2.5">
<StatusDot tone="live" />
<span className="text-[12px] text-[#6b7075]">已上线</span>
<span className={`font-en ml-auto text-[11px] uppercase tracking-[0.14em] ${labelCls}`}>02 / People</span>
</div>
<h3 className="mt-5 text-[22px] font-semibold tracking-tight">组织管理</h3>
<p className="mt-3 text-[14px] leading-relaxed text-[#6b7075]">
真实组织树、M/P 等级、汇报线与在职状态,数据源 HR 花名册。
</p>
<div className="mt-7">
{isAdmin ? (
<Link
href="/organization"
className={`inline-flex min-h-11 items-center rounded-lg bg-[#1a1c1d] px-6 text-[13px] font-medium text-white transition-all duration-200 hover:opacity-80 active:scale-[0.98] ${focusRing}`}
>
进入人员架构
</Link>
) : (
<span className="inline-flex min-h-11 items-center rounded-lg border border-[#e7e8ea] px-6 text-[13px] text-[#6b7075]">
需管理员权限
</span>
)}
</div>
</div>
<div data-card className="rounded-xl border border-[#e7e8ea] bg-white p-8 transition-all duration-[400ms] ease-[cubic-bezier(0.22,1,0.36,1)] hover:-translate-y-1.5 hover:border-[#c9ccd1] md:col-span-5 md:p-10">
<div className="flex items-center gap-2.5">
<StatusDot tone="live" />
<span className="text-[12px] text-[#6b7075]">已上线</span>
<span className={`font-en ml-auto text-[11px] uppercase tracking-[0.14em] ${labelCls}`}>03 / Performance</span>
</div>
<h3 className="mt-5 text-[22px] font-semibold tracking-tight">绩效考核</h3>
<p className="mt-3 text-[14px] leading-relaxed text-[#6b7075]">
个人考核、公共看板与调研管理——任务进度和结果在同一处收敛。
</p>
<div className="mt-7">
<Link
href="/performance-survey"
className={`inline-flex min-h-11 items-center rounded-lg bg-[#1a1c1d] px-6 text-[13px] font-medium text-white transition-all duration-200 hover:opacity-80 active:scale-[0.98] ${focusRing}`}
>
进入绩效考核
</Link>
</div>
</div>
<div data-card className="rounded-xl border border-[#e7e8ea] bg-white p-8 transition-all duration-[400ms] ease-[cubic-bezier(0.22,1,0.36,1)] hover:-translate-y-1.5 hover:border-[#c9ccd1] md:col-span-7 md:p-10">
<div className="flex items-center gap-2.5">
<StatusDot tone="live" />
<span className="text-[12px] text-[#6b7075]">已上线</span>
<span className={`font-en ml-auto text-[11px] uppercase tracking-[0.14em] ${labelCls}`}>04 / Review</span>
</div>
<h3 className="mt-5 text-[22px] font-semibold tracking-tight">作品过稿看板</h3>
<p className="mt-3 text-[14px] leading-relaxed text-[#6b7075]">
作品提交、版本迭代与评审意见——让创意协作从上传后继续推进。
</p>
<div className="mt-7">
<Link
href="/work-review"
className={`inline-flex min-h-11 items-center rounded-lg bg-[#1a1c1d] px-6 text-[13px] font-medium text-white transition-all duration-200 hover:opacity-80 active:scale-[0.98] ${focusRing}`}
>
进入作品过稿看板
</Link>
</div>
</div>
</section>
)}
{/* ===== 微型注脚 ===== */}
<footer data-foot className="flex flex-wrap items-center gap-x-4 gap-y-2 border-t border-[#e7e8ea] py-10">
<span className="font-en text-[10px] uppercase tracking-[0.2em] text-[#86898e]">Ground</span>