'use client'; import { useEffect, useState } from 'react'; import { App, Button, Card, Checkbox, Col, InputNumber, Popconfirm, Radio, Row, Select, Space, Spin, Tag, Tooltip, } from 'antd'; import { api, errMsg } from '@/lib/api'; import { formatUtcTime } from '@/lib/format'; interface StatItem { metric: string; // help_users / total_compares / total_saved label: string; // 帮助用户 / 完成比价 / 累计节省 unit: string; // 人 / 次 / 分 mode: 'real' | 'manual' | 'random'; manual_value: number | null; random_mult_min: number; // 千分比 1000=1.0 random_mult_max: number; random_tick_seconds: number; random_anchor_minutes: number; // 触发时刻对齐偏移(距 0 点分钟数) random_kind: 'mult' | 'add'; // 自增长方式:×倍率 / +绝对增量 random_step_min: number; // 绝对增量区间(基础单位) random_step_max: number; real_offset: number; // 真实值保底值(基础单位):展示=max(真实,保底)。沿用 real_offset 列名(语义已改) allow_decrease: boolean; // 是否允许展示值下降 random_current: number | null; random_last_tick_at: string | null; real_value: number; // 当前真实值(基础单位:total_saved 为分;不含偏移) updated_at: string | null; } type IntervalUnit = 'minute' | 'hour' | 'day'; const UNIT_SECONDS: Record = { minute: 60, hour: 3600, day: 86400 }; // 间隔秒数 → 数值 + 单位(取最大的整除单位) function decomposeInterval(sec: number): { num: number; unit: IntervalUnit } { if (sec % 86400 === 0) return { num: sec / 86400, unit: 'day' }; if (sec % 3600 === 0) return { num: sec / 3600, unit: 'hour' }; return { num: Math.max(1, Math.round(sec / 60)), unit: 'minute' }; } // 每个指标的本地编辑态(均以「展示单位」存:倍率用浮点,total_saved 用元) interface Edit { mode: 'real' | 'manual' | 'random'; manual: number | null; // 展示单位 realOffset: number; // 真实值保底值(展示单位) growthKind: 'mult' | 'add'; // 自增长方式 multMin: number; // 1.0 ~ multMax: number; stepMin: number; // 绝对增量下限(展示单位) stepMax: number; intervalNum: number; // 更新间隔数值 intervalUnit: IntervalUnit; // 分钟/小时/天 anchorHour: number; // 单位=天时的「每日时刻」时(0-23) anchorMinute: number; // 天:每日时刻分 / 小时:每小时第几分(0-59) allowDecrease: boolean; // 允许展示值下降(默认 false) initial: number | null; // 切 random 的初始基数(展示单位),留空=用真实值播种 } const isMoney = (metric: string) => metric === 'total_saved'; const dispUnit = (metric: string) => (isMoney(metric) ? '元' : ''); // 基础单位 → 展示单位 function baseToDisp(metric: string, v: number | null): number | null { if (v == null) return null; return isMoney(metric) ? v / 100 : v; } // 展示单位 → 基础单位(整数) function dispToBase(metric: string, v: number | null): number | null { if (v == null) return null; return isMoney(metric) ? Math.round(v * 100) : Math.round(v); } function toEdit(it: StatItem): Edit { const { num, unit } = decomposeInterval(it.random_tick_seconds); const anchor = it.random_anchor_minutes || 0; return { mode: it.mode, manual: baseToDisp(it.metric, it.manual_value), realOffset: (baseToDisp(it.metric, it.real_offset) as number) ?? 0, growthKind: it.random_kind === 'add' ? 'add' : 'mult', multMin: it.random_mult_min / 1000, multMax: it.random_mult_max / 1000, stepMin: (baseToDisp(it.metric, it.random_step_min) as number) ?? 0, stepMax: (baseToDisp(it.metric, it.random_step_max) as number) ?? 0, intervalNum: num, intervalUnit: unit, anchorHour: Math.floor(anchor / 60), anchorMinute: anchor % 60, allowDecrease: it.allow_decrease, initial: null, }; } function fmtReal(it: StatItem): string { const v = baseToDisp(it.metric, it.real_value); return isMoney(it.metric) ? `${(v as number).toFixed(1)} 元` : `${v} ${it.unit}`; } function fmtDisp(metric: string, base: number, unit: string): string { const v = baseToDisp(metric, base); return `${v} ${dispUnit(metric) || unit}`; } // 下次刷新「预计值」(基于已保存配置,经只增不减护栏):自增长给区间,真实/自定义给目标值。 function predictNext(it: StatItem): string | null { const cur = it.random_current; if (cur == null) return null; const u = dispUnit(it.metric) || it.unit; const d = (v: number) => baseToDisp(it.metric, v); if (it.mode === 'random') { if (it.random_kind === 'add') { return `${d(cur + it.random_step_min)} ~ ${d(cur + it.random_step_max)} ${u}`; } const lo = Math.floor((cur * it.random_mult_min) / 1000); const hi = Math.floor((cur * it.random_mult_max) / 1000); return `${d(lo)} ~ ${d(hi)} ${u}`; } const target = it.mode === 'real' ? Math.max(it.real_value, it.real_offset) : it.manual_value ?? cur; const eff = !it.allow_decrease && target < cur ? cur : target; return `${it.mode === 'real' ? '≈ ' : ''}${d(eff)} ${u}`; } // 下个「更新时刻」(北京时间)文案:边界 = 北京时间 anchor + k*interval(与后端 _refresh 对齐) function nextRefreshLabel(tickSeconds: number, anchorMinutes: number): string { const BJ = 8 * 3600; const interval = Math.max(60, tickSeconds); const anchorSec = ((anchorMinutes * 60) % interval + interval) % interval; const bjNow = Math.floor(Date.now() / 1000) + BJ; const k = Math.floor((bjNow - anchorSec) / interval) + 1; const nextUtcMs = (anchorSec + k * interval - BJ) * 1000; return new Date(nextUtcMs).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false, }); } // ===== 三者关系「智能校验」(劝阻式,非硬拦)===== // 触发阈值比「理想区间」宽,只在明显离谱时弹窗;理想区间在弹窗里作为建议展示。 const REL_PER_USER = { min: 2, max: 20, ideal: '4~8 次/人' }; // 人均比价 = 完成比价 / 帮助用户 const REL_PER_COMPARE = { min: 2, max: 30, ideal: '5~12 元/次' }; // 每次省额 = 累计节省(元) / 完成比价 function fmtNum(n: number): string { return n >= 10000 ? `${(n / 10000).toFixed(2).replace(/\.?0+$/, '')}万` : String(Math.round(n)); } // 只增不减护栏的前端镜像:目标低于现值且未允许下降时,实际仍显示现值。 function guardedBase(allowDecrease: boolean, current: number | null, target: number | null): number | null { if (target == null) return current; if (!allowDecrease && current != null && target < current) return current; return target; } // 本次保存后,该指标「大概会显示」的值(基础单位:total_saved 为分)。 function intendedBase(it: StatItem, e: Edit): number | null { const cur = it.random_current; if (e.mode === 'manual') { const t = e.manual != null ? (dispToBase(it.metric, e.manual) as number) : cur; return guardedBase(e.allowDecrease, cur, t); } if (e.mode === 'real') { const t = Math.max(it.real_value, (dispToBase(it.metric, e.realOffset) as number) ?? 0); return guardedBase(e.allowDecrease, cur, t); } // random:填了初始基数=设起点(过护栏);留空=保持现展示值 if (e.initial != null) return guardedBase(e.allowDecrease, cur, dispToBase(it.metric, e.initial) as number); return cur; } // 校验三者关系。返回 null=数据不全无法判断(跳过校验);否则 warnings 为空=合理。 // 被改的指标用「本次要设的目标值」,另两个用各自当前展示值 random_current。 function checkRelations( items: StatItem[], edits: Record, ): { warnings: string[]; users: number; compares: number; savedYuan: number } | null { const valBase = (metric: string): number | null => { const it = items.find((x) => x.metric === metric); const e = it ? edits[metric] : undefined; return it && e ? intendedBase(it, e) : null; }; const users = valBase('help_users'); const compares = valBase('total_compares'); const savedBase = valBase('total_saved'); if (users == null || compares == null || savedBase == null) return null; if (users <= 0 || compares <= 0 || savedBase <= 0) return null; const savedYuan = savedBase / 100; const warnings: string[] = []; if (compares < users) { warnings.push(`完成比价(${fmtNum(compares)})少于帮助用户(${fmtNum(users)}),正常应「次数 ≥ 人数」`); } const perUser = compares / users; if (perUser < REL_PER_USER.min || perUser > REL_PER_USER.max) { warnings.push( `人均比价 ${perUser.toFixed(1)} 次/人,${perUser < REL_PER_USER.min ? '偏低' : '偏高'}(建议 ${REL_PER_USER.ideal})`, ); } const perCompare = savedYuan / compares; if (perCompare < REL_PER_COMPARE.min || perCompare > REL_PER_COMPARE.max) { warnings.push( `每次省 ${perCompare.toFixed(1)} 元/次,${perCompare < REL_PER_COMPARE.min ? '偏低' : '偏高'}(建议 ${REL_PER_COMPARE.ideal})`, ); } return { warnings, users, compares, savedYuan }; } // 把某指标的编辑态打包成 PATCH body(三项共用,逐个指标各打一份)。 function buildBody(metric: string, e: Edit, immediate: boolean): Record { // 自定义不暴露间隔,固定为每天(每日「更新时间」刷新一次);其余模式用所选间隔。 const intervalSec = e.mode === 'manual' ? 86400 : Math.max(60, Math.round(e.intervalNum) * UNIT_SECONDS[e.intervalUnit]); const anchorMin = e.anchorHour * 60 + e.anchorMinute; // 更新时间(全天 HH:MM),后端按间隔取相位 const body: Record = { mode: e.mode, random_mult_min: Math.round(e.multMin * 1000), random_mult_max: Math.round(e.multMax * 1000), random_tick_seconds: intervalSec, random_anchor_minutes: anchorMin, random_kind: e.growthKind, random_step_min: (dispToBase(metric, e.stepMin) as number) ?? 0, random_step_max: (dispToBase(metric, e.stepMax) as number) ?? 0, real_offset: (dispToBase(metric, e.realOffset) as number) ?? 0, allow_decrease: e.allowDecrease, }; if (e.manual != null) body.manual_value = dispToBase(metric, e.manual) as number; if (e.initial != null) body.random_initial = dispToBase(metric, e.initial) as number; if (immediate) body.apply_now = true; return body; } /** 首页三门面数字(帮助用户/完成比价/累计节省)的展示模式配置。「系统配置 / 首页」里的一个区块。 */ export default function HomeStatsConfig() { const { message, modal } = App.useApp(); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [edits, setEdits] = useState>({}); const [saving, setSaving] = useState(null); // 首次加载:带 Spin,并初始化输入态 edits。 const load = async () => { setLoading(true); try { const { data } = await api.get('/admin/api/dashboard-display'); setItems(data); setEdits(Object.fromEntries(data.map((i) => [i.metric, toEdit(i)]))); } finally { setLoading(false); } }; // 静默刷新:只更新展示数据(items),不动 edits(不覆盖正在输入的内容)、不弹 Spin。 const silentLoad = async () => { try { const { data } = await api.get('/admin/api/dashboard-display'); setItems(data); } catch { /* 轮询失败忽略,下次再拉 */ } }; useEffect(() => { load(); const timer = setInterval(silentLoad, 3000); // 每 3s 静默刷新展示值 return () => clearInterval(timer); }, []); const patch = (metric: string, p: Partial) => setEdits((s) => ({ ...s, [metric]: { ...s[metric], ...p } })); // 三项共享:一次保存 / 立即更新全部三个指标(各打一份 PATCH)。 // 校验三者最终关系,不合理则弹窗劝阻、确认才提交。 const saveAll = async (immediate = false) => { const proceed = async () => { setSaving('__all__'); // 逐个 PATCH(顺序提交,各自 commit 一行)。results 在 try 外声明,半失败时也能把已成功的合并进 state。 const results: StatItem[] = []; try { for (const it of items) { const { data } = await api.patch( `/admin/api/dashboard-display/${it.metric}`, buildBody(it.metric, edits[it.metric], immediate), ); results.push(data); } message.success( immediate ? '三项已立即更新,客户端下次进首页即可见' : '三项配置已保存,展示值将按各自「更新时间」刷新后生效(自定义/立即更新即时生效)', ); } catch (err) { // 前 results.length 项后端已 commit,失败的是第 results.length+1 项 message.error(`第 ${results.length + 1} 项保存失败(前 ${results.length} 项已成功): ${errMsg(err)}`); } finally { // 半失败也把已成功的就地合并进 state,避免前端显旧值与后端撕裂 if (results.length > 0) { setItems((prev) => prev.map((x) => results.find((r) => r.metric === x.metric) ?? x)); setEdits((s) => ({ ...s, ...Object.fromEntries(results.map((d) => [d.metric, toEdit(d)])) })); } setSaving(null); } }; // 智能校验:按下后三者大概会呈现的关系是否合理,不合理则弹窗劝阻、确认才提交。 const rel = checkRelations(items, edits); if (rel && rel.warnings.length > 0) { modal.confirm({ title: '数据关系校验', width: 480, okText: '仍要这样设置', cancelText: '返回修改', content: (
检测到三项门面数据关系不太真实:
    {rel.warnings.map((w, i) => (
  • {w}
  • ))}
当前预计:帮助用户 {fmtNum(rel.users)} · 完成比价 {fmtNum(rel.compares)} · 累计节省{' '} {fmtNum(rel.savedYuan)}元
), onOk: proceed, }); } else { await proceed(); } }; return (

首页数据配置

客户端首页三个门面数字。每个指标独立选模式:真实值(实时查库,可设保底值)/{' '} 自定义(固定值)/ 自增长(到对齐钟点按倍率或固定增量增长)。 默认只增不减(防门面缩水);每次改动进审计日志。

{loading ? ( ) : ( {items.map((it) => { const e = edits[it.metric]; if (!e) return null; const u = dispUnit(it.metric) || it.unit; // manual 下调但未允许下降 → 提示会被护栏挡住 const manualBlocked = e.mode === 'manual' && !e.allowDecrease && e.manual != null && it.random_current != null && (dispToBase(it.metric, e.manual) as number) < it.random_current; // real 同理:max(真实值,保底值) 低于当前展示值且未允许下降 → 立即更新会被只增不减护栏挡住 const realTargetBase = Math.max(it.real_value, (dispToBase(it.metric, e.realOffset) as number) ?? 0); const realBlocked = e.mode === 'real' && !e.allowDecrease && it.random_current != null && realTargetBase < it.random_current; // 自增长:填的初始基数低于当前展示值且未允许下降 → 会被只增不减护栏挡住(保持现值) const initialBlocked = e.mode === 'random' && !e.allowDecrease && e.initial != null && it.random_current != null && (dispToBase(it.metric, e.initial) as number) < it.random_current; return ( {it.label} 当前真实值:{fmtReal(it)} } style={{ height: '100%' }} > patch(it.metric, { mode: ev.target.value })} optionType="button" buttonStyle="solid" > 真实值 自定义 自增长 {e.mode === 'manual' && (
固定值: patch(it.metric, { manual: v })} style={{ width: 200 }} /> {u} {manualBlocked && (
低于当前展示值,默认不下调(如需下调请勾「允许数字下降」)
)}
)} {e.mode === 'real' && (
保底值: patch(it.metric, { realOffset: v ?? 0 })} style={{ width: 160 }} /> {u} {realBlocked && (
真实值与保底取大后仍低于当前展示值,默认不下调(如需下调请勾「允许数字下降」)
)}
)} {e.mode === 'random' && ( <> 增长方式: patch(it.metric, { growthKind: ev.target.value })} optionType="button" > 百分比 固定增量 {e.growthKind === 'mult' ? ( 倍率区间: patch(it.metric, { multMin: v ?? 1.0 })} style={{ width: 90 }} /> ~ patch(it.metric, { multMax: v ?? 1.0 })} style={{ width: 90 }} /> ) : ( 每次新增: patch(it.metric, { stepMin: v ?? 0 })} style={{ width: 90 }} /> ~ patch(it.metric, { stepMax: v ?? 0 })} style={{ width: 90 }} /> {u} )} )} {/* 到「更新时间」按「更新间隔」刷新一次。自定义不显示间隔(固定每天该时刻)。 */} {e.mode !== 'manual' && ( 更新间隔:每 patch(it.metric, { intervalNum: v ?? 1 })} style={{ width: 80 }} />