diff --git a/backend/src/api/mkhBom.js b/backend/src/api/mkhBom.js index 9614490..7cb8b0e 100644 --- a/backend/src/api/mkhBom.js +++ b/backend/src/api/mkhBom.js @@ -32,6 +32,7 @@ function sendError(res, error, fallback) { // MKH-BOM 资料列表(模块化组合商品 + 配件汇总) router.get('/list', async (req, res) => { try { + res.set('Cache-Control', 'no-store'); res.json({ success: true, data: await getMkhBomList(req.query || {}) }); } catch (error) { sendError(res, error, '加载 MKH-BOM 资料列表失败'); @@ -42,6 +43,7 @@ router.get('/list', async (req, res) => { router.get('/detail', async (req, res) => { try { const skuId = req.query?.skuId || req.query?.comboSkuId; + res.set('Cache-Control', 'no-store'); res.json({ success: true, data: await getMkhBomDetail(skuId) }); } catch (error) { sendError(res, error, '加载 MKH-BOM 明细失败'); @@ -102,11 +104,14 @@ router.put('/notification-center', async (req, res) => { } }); -// 手动同步:清空本页服务端缓存(BOM / 补货索引 / 加工单 / 待发货 / 配件库存 / 仓位), -// 前端「同步」按钮调用后再拉一次列表即可直连副本库取最新快照 -router.post('/sync', async (_req, res) => { +// 手动同步:先清空本页服务端缓存(BOM / 补货索引 / 加工单 / 待发货 / 配件库存 / 仓位), +// 再在同一次请求中直连副本库读取新快照。这样不会被同步前尚未完成的旧列表请求覆盖。 +router.post('/sync', async (req, res) => { try { - res.json({ success: true, data: clearMkhBomCache() }); + const cache = clearMkhBomCache(); + const snapshot = await getMkhBomList(req.body?.query || {}); + res.set('Cache-Control', 'no-store'); + res.json({ success: true, data: { cache, snapshot } }); } catch (error) { sendError(res, error, '同步 MKH-BOM 数据失败'); } diff --git a/frontend/src/api/mkhBom.js b/frontend/src/api/mkhBom.js index 4d61416..dddc8cb 100644 --- a/frontend/src/api/mkhBom.js +++ b/frontend/src/api/mkhBom.js @@ -7,7 +7,11 @@ const EXPORT_TIMEOUT_MS = 5 * 60 * 1000; // MKH-BOM 资料列表(模块化组合商品 + 配件汇总) export const getMkhBomList = (params = {}) => request.get( '/api/mkh-bom/list', - { params, timeout: LIST_TIMEOUT_MS } + { + params, + timeout: LIST_TIMEOUT_MS, + headers: { 'Cache-Control': 'no-cache' }, + } ); // 单个 MKH 组合商品的 BOM 配件明细(列表展开 / 打印预览共用) @@ -46,10 +50,9 @@ export const saveMkhNotificationCenter = (payload = {}) => request.put( payload ); -// 手动同步:清空服务端缓存(BOM / 补货索引 / 加工单 / 待发货 / 配件库存 / 仓位), -// 随后重新拉列表即直连副本库取最新快照,可随时点击 -export const syncMkhBom = () => request.post( +// 手动同步:服务端清空全部 MKH 缓存,并在同一次请求中返回当前筛选条件的数据库新快照 +export const syncMkhBom = (query = {}) => request.post( '/api/mkh-bom/sync', - null, + { query }, { timeout: LIST_TIMEOUT_MS } ); diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index 231dc8e..0670914 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -17,6 +17,7 @@ import { checkMkhReplenishAlert, syncMkhBom, } from '../../api/mkhBom'; +import { createRequestGate } from '../../utils/requestGate'; import { exportTableFile } from '../../utils/tableExport'; import './style.css'; @@ -273,6 +274,8 @@ const MkhBomData = () => { const [centerSaving, setCenterSaving] = useState(false); const tableWrapRef = useRef(null); + const listRequestGateRef = useRef(createRequestGate()); + const detailGenerationRef = useRef(0); const [bodyHeight, setBodyHeight] = useState(420); useEffect(() => { @@ -310,19 +313,22 @@ const MkhBomData = () => { }, [page, pageSize, sorter, filters]); const load = useCallback(async () => { + const requestGeneration = listRequestGateRef.current.begin(); setLoading(true); setError(''); try { - const result = await getMkhBomList(query); + const result = await getMkhBomList({ ...query, _refresh: Date.now() }); + if (!listRequestGateRef.current.isCurrent(requestGeneration)) return; if (!result || result.success === false) { setError(result?.error || '加载 MKH-BOM 资料失败'); return; } setData(result?.data || { rows: [], total: 0, page: 1, pageSize: 50 }); } catch (requestError) { + if (!listRequestGateRef.current.isCurrent(requestGeneration)) return; setError(requestError?.response?.data?.error || requestError?.message || '加载 MKH-BOM 资料失败'); } finally { - setLoading(false); + if (listRequestGateRef.current.isCurrent(requestGeneration)) setLoading(false); } }, [query]); @@ -482,18 +488,23 @@ const MkhBomData = () => { const loadDetail = useCallback(async (skuId) => { if (detailCache[skuId] || detailLoading[skuId]) return; + const detailGeneration = detailGenerationRef.current; setDetailLoading((old) => ({ ...old, [skuId]: true })); try { const result = await getMkhBomDetail(skuId); + if (detailGeneration !== detailGenerationRef.current) return; if (!result || result.success === false) { message.error(result?.error || '加载配件明细失败'); return; } setDetailCache((old) => ({ ...old, [skuId]: result?.data || { items: [] } })); } catch (e) { + if (detailGeneration !== detailGenerationRef.current) return; message.error(e?.response?.data?.error || e?.message || '加载配件明细失败'); } finally { - setDetailLoading((old) => ({ ...old, [skuId]: false })); + if (detailGeneration === detailGenerationRef.current) { + setDetailLoading((old) => ({ ...old, [skuId]: false })); + } } }, [detailCache, detailLoading]); @@ -791,18 +802,35 @@ const MkhBomData = () => { ); }; - // 手动同步:清掉服务端缓存(BOM / 补货索引 / 加工单 / 待发货 / 配件库存 / 仓位), - // 再拉一次列表即可直连副本库取最新快照;可随时点击,不必等凌晨的定时刷新 + // 手动同步:服务端清缓存后在同一次请求中直连副本库返回新快照。 + // 请求代际会阻止同步前尚未完成的旧请求覆盖这个新快照。 const handleSync = async () => { setSyncing(true); + setLoading(true); + // 立即废弃同步前发出的列表/明细请求;它们即使稍后返回,也不能覆盖新快照。 + listRequestGateRef.current.invalidate(); + detailGenerationRef.current += 1; + setDetailCache({}); + setDetailLoading({}); + setExpandedKeys({}); + const syncGeneration = listRequestGateRef.current.begin(); try { - await syncMkhBom(); - setDetailCache({}); - await load(); - message.success('已同步最新数据'); + const result = await syncMkhBom(query); + if (!listRequestGateRef.current.isCurrent(syncGeneration)) return; + if (!result || result.success === false || !result?.data?.snapshot) { + throw new Error(result?.error || '同步接口未返回数据库快照'); + } + setData(result.data.snapshot); + setError(''); + const monitorDate = result.data.snapshot.monitorDate; + message.success(monitorDate ? `已读取数据库最新快照(监控日 ${monitorDate})` : '已读取数据库最新快照'); } catch (syncError) { + if (listRequestGateRef.current.isCurrent(syncGeneration)) { + setError(syncError?.response?.data?.error || syncError?.message || '同步失败'); + } message.error(syncError?.response?.data?.error || syncError?.message || '同步失败'); } finally { + if (listRequestGateRef.current.isCurrent(syncGeneration)) setLoading(false); setSyncing(false); } }; diff --git a/frontend/src/utils/requestGate.js b/frontend/src/utils/requestGate.js new file mode 100644 index 0000000..a33ec9b --- /dev/null +++ b/frontend/src/utils/requestGate.js @@ -0,0 +1,17 @@ +export function createRequestGate() { + let generation = 0; + + return { + begin() { + generation += 1; + return generation; + }, + invalidate() { + generation += 1; + return generation; + }, + isCurrent(requestGeneration) { + return requestGeneration === generation; + }, + }; +} diff --git a/frontend/test/requestGate.test.mjs b/frontend/test/requestGate.test.mjs new file mode 100644 index 0000000..f4770b6 --- /dev/null +++ b/frontend/test/requestGate.test.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createRequestGate } from '../src/utils/requestGate.js'; + +test('only the newest request may update the page', () => { + const gate = createRequestGate(); + const oldRequest = gate.begin(); + const newRequest = gate.begin(); + + assert.equal(gate.isCurrent(oldRequest), false); + assert.equal(gate.isCurrent(newRequest), true); +}); + +test('sync invalidates every request that started before cache clearing', () => { + const gate = createRequestGate(); + const beforeSync = gate.begin(); + + gate.invalidate(); + + assert.equal(gate.isCurrent(beforeSync), false); + const freshSnapshot = gate.begin(); + assert.equal(gate.isCurrent(freshSnapshot), true); +});