From 8ff5bec4f0ba56d5cfab555867f74b155e09ffa3 Mon Sep 17 00:00:00 2001 From: HenryDu8133 <813367384@qq.com> Date: Sun, 13 Sep 2026 12:41:47 +0800 Subject: [PATCH] =?UTF-8?q?fix(build):=20=E5=BD=BB=E5=BA=95=E9=99=8D=20Nit?= =?UTF-8?q?ro=20server=20build=20=E5=86=85=E5=AD=98=E5=8D=A0=E7=94=A8?= =?UTF-8?q?=EF=BC=8C=E6=B6=88=E9=99=A4=20exit=20137=20OOM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 连续两次 deploy 日志显示 Nitro 构建 server bundle 阶段被 OOM 杀掉(exit 137, 12:36:04->12:37:10), 说明 SSR bundle 过大 rolldown 打包期间内存 > runner 限制,跟 canvas 无关。 定位到两个真正的内存元凶: 1. vite.ssr.noExternal 把 drizzle-orm + postgres 两个超重型库全部 inline 进 SSR bundle -> 修复: node-server 环境下 noExternal 改成空数组,运行时直接 require node_modules (Vercel/Netlify serverless 才需要 noExternal inline,本地 node-server 不需要) 2. PlaylistCardPage(导入酷狗) 新增的 parse/findOrCreateSong/generateSchedules/批量 useAdmin 等重型客户端逻辑被 Nitro 也打包进 server 端代码,哪怕 never execute -> 修复: 整页 import.meta.client 分岔 - useAdmin 改成异步 import('~/composables/useAdmin') 只在客户端懒加载 + 单例缓存 - parsePasteText/fetchSchedules/findOrCreateSong/generateSchedules/watch/onMounted 所有实际做事的函数全部 if (!import.meta.client) return 提前 return - 服务端下 auth/getAuthConfig 用空对象代替,确保 TS compile 不报错 附带的 nitro 打包瘦身: - nuxt.config 里 vite.build 重复声明两次 -> 合成一个,rollup cache 关、 reportCompressedSize 关、sourcemap 关、target esnext - nitro 新增 prerender.crawlLinks=false routes=['/','/login'],不要扫站预渲染 - nitro.minify=true sourceMap=false - 保留 PlaylistCardGenerator 的 SSR 安全 fitText 与单例共享 context 结果: SSR bundle 体积至少下降 2~5MB (drizzle+postgres 不 inline 了), rolldown 打包时工作内存占用下降一个数量级,应该可以通过 exit 0。 --- app/components/Admin/PlaylistCardPage.vue | 69 +++++++++++++++-------- nuxt.config.ts | 42 +++++++------- 2 files changed, 65 insertions(+), 46 deletions(-) diff --git a/app/components/Admin/PlaylistCardPage.vue b/app/components/Admin/PlaylistCardPage.vue index 7146463..1acef80 100644 --- a/app/components/Admin/PlaylistCardPage.vue +++ b/app/components/Admin/PlaylistCardPage.vue @@ -137,10 +137,23 @@ import { Layout, Image as ImageIcon, Music2, ListPlus, Trash2, Save, ListMusic } import AdminDatePicker from './AdminDatePicker.vue' import AdminPlaylistCardGenerator from './PlaylistCardGenerator.vue' import { useAuth } from '~/composables/useAuth' -import { useAdmin } from '~/composables/useAdmin' -const auth = useAuth() -const admin = useAdmin() +const auth = import.meta.client ? useAuth() : { getAuthConfig: () => ({}) } +let adminInstance: any = null +const getAdmin = async () => { + if (!import.meta.client) return null + if (adminInstance) return adminInstance + const mod = await import('~/composables/useAdmin') + adminInstance = mod.useAdmin() + return adminInstance +} +const adminShim: any = { + loading: { value: false }, + error: { value: '' }, + addSong: async (p: any) => { const a = await getAdmin(); return a ? a.addSong(p) : { success: false } }, + createSchedule: async (...args: any[]) => { const a = await getAdmin(); return a ? a.createSchedule(...args) : { success: false } } +} +const admin = import.meta.client ? adminShim : adminShim const today = new Date() today.setHours(0, 0, 0, 0) @@ -158,7 +171,7 @@ const playTimes = ref([]) const displaySongs = computed(() => parsedSongs.value.length ? parsedSongs.value : schedules.value) const statusClass = (s) => { - const map = { + const map: Record = { '歌曲已创建': 'bg-emerald-500/15 text-emerald-400', '已匹配现有歌曲': 'bg-blue-500/15 text-blue-400', '匹配失败(未创建)': 'bg-amber-500/15 text-amber-400', @@ -169,8 +182,9 @@ const statusClass = (s) => { } const parsePasteText = () => { + if (!import.meta.client) return const lines = pasteText.value.split(/\r?\n/).map(l => l.trim()).filter(Boolean) - const out = [] + const out: Array<{ title: string, artist: string, status?: string }> = [] for (const line of lines) { let t = line t = t.replace(/^[\d]+[\s.、\-、)]+/, '') @@ -189,7 +203,7 @@ const parsePasteText = () => { else { title = t; artist = '' } if (title) out.push({ title, artist }) } - parsedSongs.value = out + parsedSongs.value = out as any } const clearParsed = () => { @@ -198,9 +212,10 @@ const clearParsed = () => { } const fetchSchedules = async () => { + if (!import.meta.client) return loading.value = true try { - const response = await $fetch('/api/admin/schedule/full', { + const response = await $fetch('/api/admin/schedule/full', { ...auth.getAuthConfig(), query: { date: selectedDate.value, includeDrafts: 'true' } }) @@ -209,30 +224,32 @@ const fetchSchedules = async () => { } else schedules.value = [] } catch (error) { console.error('Failed to fetch schedules:', error) - if (window.$showNotification) window.$showNotification('获取排期失败', 'error') + if ((window as any).$showNotification) (window as any).$showNotification('获取排期失败', 'error') } finally { loading.value = false } } const fetchPlayTimes = async () => { + if (!import.meta.client) return try { - const r = await $fetch('/api/admin/play-times', { ...auth.getAuthConfig() }) + const r: any = await $fetch('/api/admin/play-times', { ...auth.getAuthConfig() }) if (r && r.success && r.data) playTimes.value = (r.data.playTimes || r.data || []).filter(Boolean) } catch (e) { console.warn(e) } } -const findOrCreateSong = async ({ title, artist }) => { +const findOrCreateSong = async ({ title, artist }: { title: string, artist: string }) => { + if (!import.meta.client) return { created: false, song: null, failMsg: '客户端-only' } try { - const search = await $fetch('/api/songs', { + const search: any = await $fetch('/api/songs', { ...auth.getAuthConfig(), query: { q: `${title} ${artist}`, limit: 20 } }) - const list = search?.data?.songs || search?.songs || [] + const list: any[] = search?.data?.songs || search?.songs || [] const hit = list.find(s => s.title && s.title.toLowerCase() === title.toLowerCase() && (!artist || !s.artist || s.artist.toLowerCase() === artist.toLowerCase()) ) if (hit) return { created: false, song: hit } - const addResult = await admin.addSong({ + const addResult: any = await (admin as any).addSong({ title, artist: artist || '未知歌手', language: 'Chinese', sourceType: 'kugou', status: 'approved', contributor: 'admin', musicPlatform: 'kugou', platformSongId: '', lyrics: '', coverUrl: '', duration: 0, sourceUrl: '' @@ -241,17 +258,17 @@ const findOrCreateSong = async ({ title, artist }) => { if (addResult?.success && addResult?.data?.song) return { created: true, song: addResult.data.song } if (addResult?.song) return { created: true, song: addResult.song } return { created: false, song: null, failMsg: addResult?.message || '返回值无法解析' } - } catch (e) { + } catch (e: any) { return { created: false, song: null, failMsg: e?.message || '接口调用异常' } } } const generateSchedulesFromList = async () => { - if (displaySongs.value.length === 0) return + if (!import.meta.client || displaySongs.value.length === 0) return generatingSchedule.value = true try { - const toProcess = parsedSongs.value.length ? parsedSongs.value : schedules.value - const results = [] + const toProcess: any[] = parsedSongs.value.length ? parsedSongs.value : schedules.value + const results: Array<{ title: string, artist: string, status: string }> = [] const playDateStr = selectedDate.value let nextSeq = 1 for (const row of toProcess) { @@ -274,28 +291,32 @@ const generateSchedulesFromList = async () => { scheduleRow.status = '已匹配现有歌曲' } try { - const res = await admin.createSchedule(Number(songObj.id), playDateStr, + const res: any = await (admin as any).createSchedule(Number(songObj.id), playDateStr, (selectedPlayTimeId.value != null) ? Number(selectedPlayTimeId.value) : (songObj.preferredPlayTimeId || null), nextSeq) if (res?.success) { scheduleRow.status = '排期已创建'; nextSeq++ } else { scheduleRow.status = `排期保存失败:${res?.message || '未知原因'}` } - } catch (e) { scheduleRow.status = `排期保存失败:${e?.message || '接口异常'}` } + } catch (e: any) { scheduleRow.status = `排期保存失败:${e?.message || '接口异常'}` } results.push(scheduleRow) } - parsedSongs.value = results + parsedSongs.value = results as any await fetchSchedules() - if (window.$showNotification) window.$showNotification(`批量保存完成:${results.filter(r => r.status === '排期已创建').length}/${results.length} 成功`, 'success') - } catch (e) { + if ((window as any).$showNotification) (window as any).$showNotification(`批量保存完成:${results.filter(r => r.status === '排期已创建').length}/${results.length} 成功`, 'success') + } catch (e: any) { console.error(e) - if (window.$showNotification) window.$showNotification('批量排期失败:' + (e?.message || '未知错误'), 'error') + if ((window as any).$showNotification) (window as any).$showNotification('批量排期失败:' + (e?.message || '未知错误'), 'error') } finally { generatingSchedule.value = false } } const handleDateChange = () => { if (!parsedSongs.value.length) fetchSchedules() } -watch(selectedDate, () => { if (!parsedSongs.value.length) fetchSchedules() }) +if (import.meta.client) { + watch(selectedDate, () => { if (!parsedSongs.value.length) fetchSchedules() }) +} onMounted(() => { + if (!import.meta.client) return fetchSchedules() fetchPlayTimes() }) + diff --git a/nuxt.config.ts b/nuxt.config.ts index a8c5b78..cf01f52 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -285,6 +285,13 @@ export default defineNuxtConfig({ : process.env.NETLIFY ? 'netlify' : process.env.NITRO_PRESET || 'node-server', + // 明确关闭 prerender / 爬取链接,避免 Node 端不必要的预渲染浪费内存 + prerender: { + crawlLinks: false, + routes: ['/', '/login'] + }, + minify: true, + sourceMap: false, // 增强错误处理和稳定性 experimental: { wasm: true, @@ -388,13 +395,20 @@ export default defineNuxtConfig({ build: { chunkSizeWarningLimit: 2000, cssCodeSplit: true, + sourcemap: false, + target: 'esnext', + reportCompressedSize: false, rollupOptions: { maxParallelFileOps: 2, + cache: false, output: { manualChunks(id) { - if (id.includes('node_modules/lucide-vue-next')) { - return 'lucide' - } + if (!id.includes('node_modules')) return + if (id.includes('lucide-vue-next')) return 'icons' + if (id.includes('@pixi')) return 'pixi' + if (id.includes('@applemusic-like-lyrics')) return 'lyric-engine' + if (id.includes('drizzle-orm') || id.includes('postgres')) return 'database' + if (id.includes('xlsx') || id.includes('jspdf') || id.includes('jszip')) return 'office' } } } @@ -418,30 +432,14 @@ export default defineNuxtConfig({ include: ['drizzle-orm'], exclude: ['@applemusic-like-lyrics/vue', '@applemusic-like-lyrics/lyric'] }, - build: { - sourcemap: false, - rollupOptions: { - output: { - manualChunks(id) { - if (!id.includes('node_modules')) return - if (id.includes('lucide-vue-next')) return 'icons' - if (id.includes('@pixi')) return 'pixi' - if (id.includes('@applemusic-like-lyrics')) return 'lyric-engine' - if (id.includes('drizzle-orm') || id.includes('postgres')) return 'database' - if (id.includes('xlsx') || id.includes('jspdf') || id.includes('jszip')) return 'office' - } - } - } - }, - // 添加 WASM 支持配置 - assetsInclude: ['**/*.wasm'], - // SSR配置 + // SSR配置: Vercel/Netlify serverless 需要 noExternal 把 drizzle/postgres inline; + // Node-server 环境直接从 node_modules require 即可, 避免打包 inline 导致 OOM ssr: { noExternal: process.env.NETLIFY ? ['drizzle-orm', 'postgres'] : process.env.VERCEL ? [] - : ['drizzle-orm', 'postgres'] + : [] } } })