| | |
| | | import type { ScreenData, TimeRange } from './screen'; |
| | | import { demoScreen } from './screen-demo'; |
| | | import { |
| | | FieldTracker, |
| | | KPI_GROUPS, |
| | | groupHasLive, |
| | | resolveDataMode, |
| | | type ScreenDataMode |
| | | } from './screen-field-meta'; |
| | | import { FieldTracker, resolveDataMode, type ScreenDataMode } from './screen-field-meta'; |
| | | import { structureScreen } from './screen-structure'; |
| | | |
| | | export type { ScreenDataMode }; |
| | |
| | | liveFields: string[]; |
| | | } |
| | | |
| | | function sum(nums: number[]) { |
| | | return nums.reduce((a, b) => a + (Number(b) || 0), 0); |
| | | function num(v: unknown, fallback = 0) { |
| | | const n = Number(v); |
| | | return Number.isFinite(n) ? n : fallback; |
| | | } |
| | | |
| | | function hasPositive(nums: number[]) { |
| | | return nums.some(v => Number(v) > 0); |
| | | function alignSeries(source: number[] | undefined, len: number) { |
| | | const values = (source || []).map(v => num(v)); |
| | | if (!values.length) return Array.from({ length: len }, () => 0); |
| | | if (values.length === len) return values; |
| | | if (values.length > len) return values.slice(values.length - len); |
| | | return [...Array.from({ length: len - values.length }, () => 0), ...values]; |
| | | } |
| | | |
| | | function hasRankData(list: any[]) { |
| | | return list.length > 0 && list.some(e => Number(e?.people) > 0 || Number(e?.weight) > 0); |
| | | } |
| | | |
| | | function pickKpis( |
| | | live: Record<string, number> | undefined, |
| | | demo: Record<string, number>, |
| | | ctx: FieldTracker |
| | | ) { |
| | | const out = { ...demo }; |
| | | if (!live) { |
| | | Object.values(KPI_GROUPS) |
| | | .flat() |
| | | .forEach(key => ctx.markDemo(`kpis.${key}`)); |
| | | return out; |
| | | } |
| | | |
| | | for (const [group, keys] of Object.entries(KPI_GROUPS)) { |
| | | if (groupHasLive(keys, live)) { |
| | | keys.forEach(key => { |
| | | const v = Number(live[key]); |
| | | out[key] = Number.isFinite(v) ? v : demo[key]; |
| | | ctx.markLive(`kpis.${key}`); |
| | | }); |
| | | } else { |
| | | keys.forEach(key => ctx.markDemo(`kpis.${key}`)); |
| | | } |
| | | } |
| | | return out; |
| | | } |
| | | |
| | | function pickArray<T>( |
| | | field: string, |
| | | live: T[] | undefined, |
| | | demo: T[], |
| | | ctx: FieldTracker, |
| | | valid: (v: T[]) => boolean |
| | | ) { |
| | | if (live && valid(live)) { |
| | | ctx.markLive(field); |
| | | return live; |
| | | } |
| | | ctx.markDemo(field); |
| | | return demo; |
| | | } |
| | | |
| | | function pickTrend( |
| | | field: string, |
| | | live: { weight: number[]; value: number[]; carbon: number[] } | undefined, |
| | | demo: ScreenData['trend'], |
| | | axis: string[], |
| | | ctx: FieldTracker |
| | | ) { |
| | | const valid = |
| | | live && |
| | | live.weight?.length && |
| | | (sum(live.weight) > 0 || sum(live.value) > 0 || sum(live.carbon) > 0); |
| | | if (valid) { |
| | | ctx.markLive(field); |
| | | return { |
| | | weight: alignSeries(live!.weight, axis.length, live!.weight[live!.weight.length - 1] || 0), |
| | | value: alignSeries(live!.value, axis.length, live!.value[live!.value.length - 1] || 0), |
| | | carbon: alignSeries(live!.carbon, axis.length, live!.carbon[live!.carbon.length - 1] || 0) |
| | | }; |
| | | } |
| | | ctx.markDemo(field); |
| | | return alignTrend(demo, axis.length); |
| | | } |
| | | |
| | | function alignSeries(source: number[], len: number, fill = 0) { |
| | | if (!source?.length) return Array.from({ length: len }, () => fill); |
| | | if (source.length === len) return [...source]; |
| | | if (source.length > len) return source.slice(source.length - len); |
| | | return [...Array.from({ length: len - source.length }, () => fill), ...source]; |
| | | } |
| | | |
| | | function alignTrend(trend: ScreenData['trend'], len: number) { |
| | | return { |
| | | weight: alignSeries(trend.weight, len), |
| | | value: alignSeries(trend.value, len), |
| | | carbon: alignSeries(trend.carbon, len) |
| | | }; |
| | | } |
| | | |
| | | function pickWasteMix( |
| | | live: ScreenData['wasteMix'] | undefined, |
| | | demo: ScreenData['wasteMix'], |
| | | ctx: FieldTracker |
| | | ) { |
| | | if ( |
| | | live?.length && |
| | | live.some(e => Number(e.weight) > 0 || Number(e.count) > 0 || Number(e.value) > 0) |
| | | ) { |
| | | ctx.markLive('wasteMix'); |
| | | return live.map((item, i) => ({ |
| | | ...demo[i], |
| | | ...item, |
| | | color: item.color || demo[i]?.color |
| | | })); |
| | | } |
| | | ctx.markDemo('wasteMix'); |
| | | return demo; |
| | | } |
| | | |
| | | function pickRecycleItems( |
| | | live: ScreenData['recycleItems'] | undefined, |
| | | demo: ScreenData['recycleItems'], |
| | | ctx: FieldTracker |
| | | ) { |
| | | if (live?.length && live.some(e => Number(e.weight) > 0 || Number(e.value) > 0 || Number(e.carbon) > 0)) { |
| | | ctx.markLive('recycleItems'); |
| | | return live.map((item, i) => ({ |
| | | ...demo[i], |
| | | ...item, |
| | | color: item.color || demo[i]?.color |
| | | })); |
| | | } |
| | | ctx.markDemo('recycleItems'); |
| | | return demo; |
| | | } |
| | | |
| | | function pickPointFlow( |
| | | live: ScreenData['pointFlow'] | undefined, |
| | | demo: ScreenData['pointFlow'], |
| | | ctx: FieldTracker |
| | | ) { |
| | | const out = { ...demo }; |
| | | if (!live) { |
| | | ctx.markDemo('pointFlow'); |
| | | (['issued', 'transfer', 'donate', 'mall', 'balance'] as const).forEach(key => |
| | | ctx.markDemo(`pointFlow.${key}`) |
| | | ); |
| | | return out; |
| | | } |
| | | |
| | | let hasLive = false; |
| | | (['issued', 'transfer', 'donate', 'mall', 'balance'] as const).forEach(key => { |
| | | if (Number(live[key]) > 0) { |
| | | out[key] = live[key]; |
| | | ctx.markLive(`pointFlow.${key}`); |
| | | hasLive = true; |
| | | } else { |
| | | ctx.markDemo(`pointFlow.${key}`); |
| | | } |
| | | }); |
| | | if (hasLive) ctx.markLive('pointFlow'); |
| | | else ctx.markDemo('pointFlow'); |
| | | return out; |
| | | } |
| | | |
| | | function pickOrdersByType( |
| | | live: ScreenData['ordersByType'] | undefined, |
| | | demo: ScreenData['ordersByType'], |
| | | ctx: FieldTracker |
| | | ) { |
| | | const keys = ['现场回收', '预约上门', '大屏投递', '活动回收'] as const; |
| | | const liveValid = live?.axis?.length && keys.some(k => hasPositive(live[k] || [])); |
| | | if (liveValid) { |
| | | ctx.markLive('ordersByType'); |
| | | return live!; |
| | | } |
| | | ctx.markDemo('ordersByType'); |
| | | return demo; |
| | | } |
| | | |
| | | function pickAppointmentFlow( |
| | | live: ScreenData['appointmentFlow'] | undefined, |
| | | demo: ScreenData['appointmentFlow'], |
| | | ctx: FieldTracker |
| | | ) { |
| | | if (live?.length && live.some(e => Number(e.value) > 0)) { |
| | | ctx.markLive('appointmentFlow'); |
| | | return live; |
| | | } |
| | | ctx.markDemo('appointmentFlow'); |
| | | return demo; |
| | | } |
| | | |
| | | function pickSorterStatus( |
| | | live: ScreenData['sorterStatus'] | undefined, |
| | | demo: ScreenData['sorterStatus'], |
| | | ctx: FieldTracker |
| | | ) { |
| | | if (live?.length && sum(live.map(e => Number(e.value))) > 0) { |
| | | ctx.markLive('sorterStatus'); |
| | | return live; |
| | | } |
| | | ctx.markDemo('sorterStatus'); |
| | | return demo; |
| | | } |
| | | |
| | | function pickAxis( |
| | | live: ScreenData | null | undefined, |
| | | demo: ScreenData, |
| | | structure: ScreenData, |
| | | ctx: FieldTracker |
| | | ) { |
| | | const useLiveAxis = |
| | | live?.axis?.length && |
| | | (sum(live.trend?.weight || []) > 0 || |
| | | sum(live.participateTrend || []) > 0 || |
| | | sum(live.deliverTrend || []) > 0 || |
| | | live.axis.length > 1); |
| | | if (useLiveAxis) { |
| | | ctx.markLive('axis'); |
| | | return live!.axis; |
| | | } |
| | | ctx.markDemo('axis'); |
| | | return demo.axis.length ? demo.axis : structure.axis; |
| | | } |
| | | |
| | | /** 结构 + 演示 + 真实数据按字段/分组合并 */ |
| | | /** 用接口结果覆盖零值结构。缺字段保持 0 或空列表,不再回落到演示数据。 */ |
| | | export function resolveScreenData(live: ScreenData | null | undefined, range: TimeRange): ResolvedScreenData { |
| | | const structure = structureScreen(range); |
| | | const demo = demoScreen(range); |
| | | const base = structureScreen(range); |
| | | const ctx = new FieldTracker(); |
| | | |
| | | const axis = pickAxis(live, demo, structure, ctx); |
| | | const kpis = pickKpis(live?.kpis, demo.kpis, ctx); |
| | | const wasteMix = pickWasteMix(live?.wasteMix, demo.wasteMix, ctx); |
| | | const recycleItems = pickRecycleItems(live?.recycleItems, demo.recycleItems, ctx); |
| | | const trend = pickTrend('trend', live?.trend, demo.trend, axis, ctx); |
| | | |
| | | let yearCarbon = demo.yearCarbon; |
| | | if (live?.yearCarbon?.length && sum(live.yearCarbon) > 0) { |
| | | yearCarbon = live.yearCarbon; |
| | | ctx.markLive('yearCarbon'); |
| | | } else { |
| | | ctx.markDemo('yearCarbon'); |
| | | if (!live) { |
| | | return { |
| | | data: base, |
| | | mode: 'live', |
| | | demoFields: [], |
| | | liveFields: [] |
| | | }; |
| | | } |
| | | |
| | | const deptRank = pickArray('deptRank', live?.deptRank, demo.deptRank, ctx, hasRankData); |
| | | const classRank = pickArray('classRank', live?.classRank, demo.classRank, ctx, hasRankData); |
| | | const majorRank = pickArray('majorRank', live?.majorRank, demo.majorRank, ctx, hasRankData); |
| | | const pointFlow = pickPointFlow(live?.pointFlow, demo.pointFlow, ctx); |
| | | const axis = live.axis?.length ? live.axis : base.axis; |
| | | ctx.markLive('axis'); |
| | | |
| | | let participateTrend = alignSeries(demo.participateTrend, axis.length); |
| | | if (live?.participateTrend?.length && sum(live.participateTrend) > 0) { |
| | | participateTrend = alignSeries(live.participateTrend, axis.length); |
| | | ctx.markLive('participateTrend'); |
| | | } else { |
| | | ctx.markDemo('participateTrend'); |
| | | } |
| | | const kpis = { ...base.kpis }; |
| | | Object.keys(kpis).forEach(key => { |
| | | if (live.kpis && live.kpis[key] != null) { |
| | | kpis[key] = num(live.kpis[key]); |
| | | } |
| | | ctx.markLive(`kpis.${key}`); |
| | | }); |
| | | |
| | | let deliverTrend = alignSeries(demo.deliverTrend, axis.length); |
| | | if (live?.deliverTrend?.length && sum(live.deliverTrend) > 0) { |
| | | deliverTrend = alignSeries(live.deliverTrend, axis.length); |
| | | ctx.markLive('deliverTrend'); |
| | | } else { |
| | | ctx.markDemo('deliverTrend'); |
| | | } |
| | | const wasteMix = live.wasteMix?.length |
| | | ? live.wasteMix.map((item, i) => ({ |
| | | ...base.wasteMix[i], |
| | | ...item, |
| | | weight: num(item.weight), |
| | | value: num(item.value), |
| | | count: num(item.count), |
| | | color: item.color || base.wasteMix[i]?.color |
| | | })) |
| | | : base.wasteMix; |
| | | ctx.markLive('wasteMix'); |
| | | |
| | | const ordersByType = pickOrdersByType(live?.ordersByType, demo.ordersByType, ctx); |
| | | const appointmentFlow = pickAppointmentFlow(live?.appointmentFlow, demo.appointmentFlow, ctx); |
| | | const sorterStatus = pickSorterStatus(live?.sorterStatus, demo.sorterStatus, ctx); |
| | | const vehicles = pickArray('vehicles', live?.vehicles, demo.vehicles, ctx, v => v.length > 0); |
| | | const sites = pickArray('sites', live?.sites, demo.sites, ctx, v => v.length > 0); |
| | | const recycleItems = live.recycleItems?.length |
| | | ? live.recycleItems.map((item, i) => ({ |
| | | ...base.recycleItems[i], |
| | | ...item, |
| | | weight: num(item.weight), |
| | | value: num(item.value), |
| | | carbon: num(item.carbon), |
| | | color: item.color || base.recycleItems[i]?.color |
| | | })) |
| | | : base.recycleItems; |
| | | ctx.markLive('recycleItems'); |
| | | |
| | | const trend = { |
| | | weight: alignSeries(live.trend?.weight, axis.length), |
| | | value: alignSeries(live.trend?.value, axis.length), |
| | | carbon: alignSeries(live.trend?.carbon, axis.length) |
| | | }; |
| | | ctx.markLive('trend'); |
| | | |
| | | const yearCarbon = live.yearCarbon?.length |
| | | ? alignSeries(live.yearCarbon, 12) |
| | | : base.yearCarbon; |
| | | ctx.markLive('yearCarbon'); |
| | | |
| | | const deptRank = Array.isArray(live.deptRank) ? live.deptRank : []; |
| | | const classRank = Array.isArray(live.classRank) ? live.classRank : []; |
| | | const majorRank = Array.isArray(live.majorRank) ? live.majorRank : []; |
| | | ctx.markLive('deptRank'); |
| | | ctx.markLive('classRank'); |
| | | ctx.markLive('majorRank'); |
| | | |
| | | const pointFlow = { |
| | | issued: num(live.pointFlow?.issued), |
| | | transfer: num(live.pointFlow?.transfer), |
| | | donate: num(live.pointFlow?.donate), |
| | | mall: num(live.pointFlow?.mall), |
| | | balance: num(live.pointFlow?.balance) |
| | | }; |
| | | ctx.markLive('pointFlow'); |
| | | |
| | | const participateTrend = alignSeries(live.participateTrend, axis.length); |
| | | const deliverTrend = alignSeries(live.deliverTrend, axis.length); |
| | | ctx.markLive('participateTrend'); |
| | | ctx.markLive('deliverTrend'); |
| | | |
| | | const ordersByType = live.ordersByType?.axis?.length |
| | | ? { |
| | | axis: live.ordersByType.axis, |
| | | 现场回收: alignSeries(live.ordersByType.现场回收, live.ordersByType.axis.length), |
| | | 预约上门: alignSeries(live.ordersByType.预约上门, live.ordersByType.axis.length), |
| | | 大屏投递: alignSeries(live.ordersByType.大屏投递, live.ordersByType.axis.length), |
| | | 活动回收: alignSeries(live.ordersByType.活动回收, live.ordersByType.axis.length) |
| | | } |
| | | : base.ordersByType; |
| | | ctx.markLive('ordersByType'); |
| | | |
| | | const appointmentFlow = live.appointmentFlow?.length ? live.appointmentFlow : base.appointmentFlow; |
| | | const sorterStatus = live.sorterStatus?.length ? live.sorterStatus : base.sorterStatus; |
| | | const vehicles = Array.isArray(live.vehicles) ? live.vehicles : []; |
| | | const sites = Array.isArray(live.sites) ? live.sites : []; |
| | | ctx.markLive('appointmentFlow'); |
| | | ctx.markLive('sorterStatus'); |
| | | ctx.markLive('vehicles'); |
| | | ctx.markLive('sites'); |
| | | |
| | | const data: ScreenData = { |
| | | range, |
| | |
| | | sorterStatus, |
| | | vehicles, |
| | | sites, |
| | | unavailable: live?.unavailable || [] |
| | | unavailable: live.unavailable || [] |
| | | }; |
| | | |
| | | return { |
| | | data, |
| | | mode: resolveDataMode(live, ctx), |
| | | demoFields: ctx.listDemo(), |
| | | demoFields: [], |
| | | liveFields: ctx.listLive() |
| | | }; |
| | | } |
| | | |
| | | /** 接口失败或无 live 数据时的初始展示 */ |
| | | /** 接口尚未返回时先展示零值,避免先闪出演示数字 */ |
| | | export function initialScreenData(range: TimeRange = 'month') { |
| | | return resolveScreenData(null, range); |
| | | } |
| | | |
| | | export { structureScreen, buildScreenAxis } from './screen-structure'; |
| | | export { demoScreen } from './screen-demo'; |