/* ============================================================== * 指标变化展示规则纯函数 - 单一事实来源 * 规则来源:design.md §6.6 + PRD §5.5 + §6.2.1 trendCode 五值契约 * 核心约束:前端**不得**根据 `delta` 的正负自行推导"改善/恶化", * 只能消费后端 `trendCode` 枚举。这是保证全系统一致的唯一可靠方式。 * ============================================================== */ /** * 【单一事实来源】由后端 trendCode + movement 推导展示样式。 * 所有展示变化的位置(报告详情/身维度/速查/菌群/时间轴/管理端)必须走这里。 * * trendCode 五值枚举(含 D12 的 pending_backfill): * 'improved' | 'worsened' | 'flat' | 'first_record' | 'pending_backfill' * * 关键约定: * 1. trendLabel 优先取后端下发的值,前端不改写(合规:文案统一) * 2. 前端【禁止】用 previous == null 自行判断是"首次"还是"整理中" —— 只能看 trendCode * 3. movement === 'none' 时不渲染箭头 * * @param {string} trendCode * @param {string} movement 'up'|'down'|'flat'|'changed'|'unchanged'|'none' * @param {string} backendLabel 后端下发的 trendLabel(优先使用,为空时才用本地兜底) * @returns {{trendLabel:string, cssClass:string, arrow:string, colorClass:string, showArrow:boolean}} */ export function resolveChange(trendCode, movement, backendLabel) { var mv = movement || 'flat' var arrow = mv === 'up' ? '↑' : (mv === 'down' ? '↓' : '→') var label = backendLabel || '' // D12 新增两档:无对比对象。灰色、无箭头。 if (trendCode === 'first_record') { return { trendLabel: label || '首次记录 · 暂无对比', cssClass: 'degrade', arrow: '', colorClass: 'gray-text', showArrow: false } } if (trendCode === 'pending_backfill') { return { trendLabel: label || '历史数据整理中,暂无法对比', cssClass: 'degrade', arrow: '', colorClass: 'gray-text', showArrow: false } } if (trendCode === 'improved') { return { trendLabel: label || '改善', cssClass: 'impr', arrow: arrow, colorClass: 'green-text', showArrow: true } } if (trendCode === 'worsened') { return { trendLabel: label || '恶化', cssClass: 'worst', arrow: arrow, colorClass: 'red-text', showArrow: true } } return { trendLabel: label || '基本持平', cssClass: 'flat', arrow: mv === 'none' ? '' : arrow, colorClass: 'gray-text', showArrow: mv !== 'none' } } /** * 非数值指标:有变化 / 无变化,不给颜色(合规红线) */ export function resolveTextChange(movement) { return movement === 'unchanged' ? { trendLabel: '无变化', cssClass: 'flat', arrow: '→', colorClass: 'gray-text' } : { trendLabel: '有变化', cssClass: 'flat', arrow: '→', colorClass: 'gray-text' } } /** * Δ 展示文本(PRD §6.4)。列表窄位省略单位由调用方传 unit='' 实现。 * @param {number|null} delta * @param {number|null} deltaPct * @param {boolean} unitMismatch 单位跨期不一致 → 百分比显示 '—' */ export function formatDelta(delta, deltaPct, unitMismatch) { if (delta === null || delta === undefined || isNaN(delta)) return '--' var sign = delta > 0 ? '+' : '' var d = roundSig(delta) // PRD §6.4 有效数字规则 if (unitMismatch || deltaPct === null || deltaPct === undefined || isNaN(deltaPct)) { return sign + d + ' (—)' } return sign + d + ' (' + (deltaPct > 0 ? '+' : '') + deltaPct + '%)' } /** PRD §6.4 有效数字:≥100 整数;10–100 一位小数;<10 两位小数 */ export function roundSig(v) { var a = Math.abs(v) if (a >= 100) return String(Math.round(v)) if (a >= 10) return v.toFixed(1) return v.toFixed(2) } /** * 「较 MM-DD」日期文案(PRD §6.6) * @param {string} prevDate 'yyyy-MM-dd' * @param {string} currDate 'yyyy-MM-dd' 用于判断是否同年 * @param {string} dateSource 'real'|'upload'|'confirm'|'unknown' */ export function formatCompareDate(prevDate, currDate, dateSource) { if (!prevDate) return '日期未知' var star = (dateSource && dateSource !== 'real' && dateSource !== 'unknown') ? '*' : '' if (!currDate) return prevDate + star var sameYear = String(prevDate).slice(0, 4) === String(currDate).slice(0, 4) return (sameYear ? String(prevDate).slice(5) : prevDate) + star } /** * 图表形态 → 是否画图 / 是否标值(消费后端 chartMode,不自行判断) * @param {string} chartMode 'empty'|'single'|'pair'|'line'|'line_aggregated' * @param {number} n 期数 */ export function chartRenderConfig(chartMode, n) { if (chartMode === 'empty' || chartMode === 'single') return { draw: false, showValues: false, labelMode: 'none' } if (chartMode === 'pair') return { draw: false, showValues: true, labelMode: 'all' } if (chartMode === 'line_aggregated') return { draw: true, showValues: false, labelMode: 'sparse' } return { draw: true, showValues: n <= 8, labelMode: n <= 8 ? 'all' : 'sparse' } } /** X 轴标签下标抽稀(PRD §5.2 选型表) */ export function axisLabelIndices(n) { if (n <= 8) { var all = []; for (var i = 0; i < n; i++) all.push(i); return all } if (n <= 19) return unique([0, Math.floor((n - 1) / 3), Math.floor(2 * (n - 1) / 3), n - 1]) return unique([0, Math.floor(n / 2), n - 1]) } // ---------- 兼容保留 ---------- /** * @deprecated 仅供无 trendCode 的老数据兜底(如 member_indicator_current.trend)。 * 新代码一律用 resolveChange(trendCode, movement)。 * 保留原实现不删(member_indicator_current 链路仍只有 trend 字段,无 trendCode)。 * * 由 good_direction × movement 推导展示样式 * @param {string} goodDirection higher_is_better|lower_is_better|in_range_is_better|neutral * @param {string} movement up|down|flat * @returns {{trendLabel:string, cssClass:string, arrow:string}} */ export function resolveChangeStyle(goodDirection, movement) { var gd = goodDirection || 'neutral' var mv = movement || 'flat' if (mv === 'flat') { return { trendLabel: '中性', cssClass: 'flat', arrow: '→' } } // 是否"改善":higher_is_better 升=改善;lower_is_better 降=改善;in_range 视区间判定(此处交给 withRange 变体) var isImprove = false if (gd === 'higher_is_better') { isImprove = mv === 'up' } else if (gd === 'lower_is_better') { isImprove = mv === 'down' } else { // neutral / in_range_is_better(一期 in_range 的管理端手配方向;区间内移动判定留二期,先按中性展示) return { trendLabel: '中性', cssClass: gd === 'in_range_is_better' ? 'neutral-up' : mv === 'up' ? 'neutral-up' : 'neutral-down', arrow: mv === 'up' ? '↑' : '↓' } } if (isImprove) { return { trendLabel: '改善', cssClass: gd === 'higher_is_better' ? 'impr-up' : 'impr-down', arrow: mv === 'up' ? '↑' : '↓' } } return { trendLabel: '恶化', cssClass: gd === 'higher_is_better' ? 'worst-down' : 'worst-up', arrow: mv === 'up' ? '↑' : '↓' } } /** * CSS 颜色类:改善=green-text 恶化=red-text 中性=gray-text * @param {string} cssClass resolveChangeStyle 返回的类 * @returns {string} */ export function colorClassFor(cssClass) { if (!cssClass) return 'gray-text' if (cssClass.indexOf('impr') === 0) return 'green-text' if (cssClass.indexOf('worst') === 0) return 'red-text' return 'gray-text' } /** 去重并升序(下标抽稀用) */ function unique(arr) { var seen = {} var out = [] for (var i = 0; i < arr.length; i++) { var v = arr[i] if (!seen[v]) { seen[v] = true; out.push(v) } } out.sort(function(a, b) { return a - b }) return out }