05a1d06e61
review 发现 4 处仍用 new Date(v).toLocaleString 渲染后端 UTC 口径时间戳—— 正是 lib/format.ts 头注释明令消灭的反模式(SQLite 本地/非中国浏览器会差 8h, 生产靠 +00:00 偏移侥幸正确)。统一改用 formatUtcTime 按北京显示: - admins: last_login_at - audit-logs: created_at - devices: completed_at - dashboard/HomeStatsConfig: ops_stat_config.updated_at 均为 server_default now()/datetime.now(utc) 的 UTC 口径字段。 HomeStatsConfig 的「下个更新时刻」用 epoch ms + 强制 Asia/Shanghai,正确,不动。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: OuYingJun1024 <1034284404@qq.com> Reviewed-on: #10 Co-authored-by: ouzhou <ouzhou@wonderable.ai> Co-committed-by: ouzhou <ouzhou@wonderable.ai>
649 lines
28 KiB
TypeScript
649 lines
28 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import {
|
|
Button,
|
|
Card,
|
|
Checkbox,
|
|
Col,
|
|
InputNumber,
|
|
Modal,
|
|
Popconfirm,
|
|
Radio,
|
|
Row,
|
|
Select,
|
|
Space,
|
|
Spin,
|
|
Tag,
|
|
Tooltip,
|
|
message,
|
|
} 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<IntervalUnit, number> = { 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<string, Edit>,
|
|
): { 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<string, number | string | boolean> {
|
|
// 自定义不暴露间隔,固定为每天(每日「更新时间」刷新一次);其余模式用所选间隔。
|
|
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<string, number | string | boolean> = {
|
|
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 [items, setItems] = useState<StatItem[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [edits, setEdits] = useState<Record<string, Edit>>({});
|
|
const [saving, setSaving] = useState<string | null>(null);
|
|
|
|
// 首次加载:带 Spin,并初始化输入态 edits。
|
|
const load = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const { data } = await api.get<StatItem[]>('/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<StatItem[]>('/admin/api/dashboard-display');
|
|
setItems(data);
|
|
} catch {
|
|
/* 轮询失败忽略,下次再拉 */
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
load();
|
|
const timer = setInterval(silentLoad, 3000); // 每 3s 静默刷新展示值
|
|
return () => clearInterval(timer);
|
|
}, []);
|
|
|
|
const patch = (metric: string, p: Partial<Edit>) =>
|
|
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<StatItem>(
|
|
`/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: (
|
|
<div style={{ fontSize: 13 }}>
|
|
<div style={{ marginBottom: 8 }}>检测到三项门面数据关系不太真实:</div>
|
|
<ul style={{ paddingLeft: 18, margin: '0 0 8px' }}>
|
|
{rel.warnings.map((w, i) => (
|
|
<li key={i} style={{ color: '#d4380d', marginBottom: 2 }}>
|
|
{w}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
<div style={{ color: '#888' }}>
|
|
当前预计:帮助用户 {fmtNum(rel.users)} · 完成比价 {fmtNum(rel.compares)} · 累计节省{' '}
|
|
{fmtNum(rel.savedYuan)}元
|
|
</div>
|
|
</div>
|
|
),
|
|
onOk: proceed,
|
|
});
|
|
} else {
|
|
await proceed();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div style={{ marginTop: 24 }}>
|
|
<h3 style={{ marginBottom: 4 }}>首页数据配置</h3>
|
|
<p style={{ color: '#999', marginTop: 0 }}>
|
|
客户端首页三个门面数字。每个指标独立选模式:<b>真实值</b>(实时查库,可设保底值)/{' '}
|
|
<b>自定义</b>(固定值)/ <b>自增长</b>(到对齐钟点按倍率或固定增量增长)。
|
|
默认<b>只增不减</b>(防门面缩水);每次改动进审计日志。
|
|
</p>
|
|
{loading ? (
|
|
<Spin style={{ display: 'block', marginTop: 24 }} />
|
|
) : (
|
|
<Row gutter={[16, 16]} align="top">
|
|
{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 (
|
|
<Col key={it.metric} xs={24} md={12} xl={8}>
|
|
<Card
|
|
size="small"
|
|
title={
|
|
<span>
|
|
{it.label}
|
|
<span style={{ color: '#999', fontWeight: 400, marginLeft: 8, fontSize: 13 }}>
|
|
当前真实值:{fmtReal(it)}
|
|
</span>
|
|
</span>
|
|
}
|
|
style={{ height: '100%' }}
|
|
>
|
|
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
|
<Radio.Group
|
|
value={e.mode}
|
|
onChange={(ev) => patch(it.metric, { mode: ev.target.value })}
|
|
optionType="button"
|
|
buttonStyle="solid"
|
|
>
|
|
<Radio.Button value="real">真实值</Radio.Button>
|
|
<Radio.Button value="manual">自定义</Radio.Button>
|
|
<Radio.Button value="random">自增长</Radio.Button>
|
|
</Radio.Group>
|
|
|
|
{e.mode === 'manual' && (
|
|
<div>
|
|
<Space>
|
|
<span>固定值:</span>
|
|
<InputNumber
|
|
value={e.manual}
|
|
min={0}
|
|
onChange={(v) => patch(it.metric, { manual: v })}
|
|
style={{ width: 200 }}
|
|
/>
|
|
<span style={{ color: '#666' }}>{u}</span>
|
|
</Space>
|
|
{manualBlocked && (
|
|
<div style={{ color: '#fa8c16', fontSize: 12, marginTop: 4 }}>
|
|
低于当前展示值,默认不下调(如需下调请勾「允许数字下降」)
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{e.mode === 'real' && (
|
|
<div>
|
|
<Space wrap>
|
|
<span>保底值:</span>
|
|
<InputNumber
|
|
value={e.realOffset}
|
|
min={0}
|
|
onChange={(v) => patch(it.metric, { realOffset: v ?? 0 })}
|
|
style={{ width: 160 }}
|
|
/>
|
|
<span style={{ color: '#666' }}>{u}</span>
|
|
<Tooltip title="展示值 = max(真实值, 保底值)。冷启动期真实数字小,显示保底撑场面;真实值涨过保底后显示纯真实、零差距。留 0 = 纯真实值。">
|
|
<span style={{ color: '#999', cursor: 'help' }}>ⓘ</span>
|
|
</Tooltip>
|
|
</Space>
|
|
{realBlocked && (
|
|
<div style={{ color: '#fa8c16', fontSize: 12, marginTop: 4 }}>
|
|
真实值与保底取大后仍低于当前展示值,默认不下调(如需下调请勾「允许数字下降」)
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{e.mode === 'random' && (
|
|
<>
|
|
<Space wrap>
|
|
<span>增长方式:</span>
|
|
<Radio.Group
|
|
value={e.growthKind}
|
|
size="small"
|
|
onChange={(ev) => patch(it.metric, { growthKind: ev.target.value })}
|
|
optionType="button"
|
|
>
|
|
<Radio.Button value="mult">百分比</Radio.Button>
|
|
<Radio.Button value="add">固定增量</Radio.Button>
|
|
</Radio.Group>
|
|
</Space>
|
|
{e.growthKind === 'mult' ? (
|
|
<Space wrap>
|
|
<span>倍率区间:</span>
|
|
<InputNumber
|
|
value={e.multMin}
|
|
min={1.0}
|
|
max={1.5}
|
|
step={0.01}
|
|
onChange={(v) => patch(it.metric, { multMin: v ?? 1.0 })}
|
|
style={{ width: 90 }}
|
|
/>
|
|
<span>~</span>
|
|
<InputNumber
|
|
value={e.multMax}
|
|
min={1.0}
|
|
max={1.5}
|
|
step={0.01}
|
|
onChange={(v) => patch(it.metric, { multMax: v ?? 1.0 })}
|
|
style={{ width: 90 }}
|
|
/>
|
|
<Tooltip title="倍率 1.0~1.5,每次更新随机取区间内一个倍率相乘(只增不减)。">
|
|
<span style={{ color: '#999', cursor: 'help' }}>ⓘ</span>
|
|
</Tooltip>
|
|
</Space>
|
|
) : (
|
|
<Space wrap>
|
|
<span>每次新增:</span>
|
|
<InputNumber
|
|
value={e.stepMin}
|
|
min={0}
|
|
onChange={(v) => patch(it.metric, { stepMin: v ?? 0 })}
|
|
style={{ width: 90 }}
|
|
/>
|
|
<span>~</span>
|
|
<InputNumber
|
|
value={e.stepMax}
|
|
min={0}
|
|
onChange={(v) => patch(it.metric, { stepMax: v ?? 0 })}
|
|
style={{ width: 90 }}
|
|
/>
|
|
<span style={{ color: '#666' }}>{u}</span>
|
|
<Tooltip title="每个更新周期随机 +[下限,上限](绝对增量),比百分比更可控、更像真实日增。">
|
|
<span style={{ color: '#999', cursor: 'help' }}>ⓘ</span>
|
|
</Tooltip>
|
|
</Space>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* 到「更新时间」按「更新间隔」刷新一次。自定义不显示间隔(固定每天该时刻)。 */}
|
|
{e.mode !== 'manual' && (
|
|
<Space wrap>
|
|
<span>更新间隔:每</span>
|
|
<InputNumber
|
|
value={e.intervalNum}
|
|
min={1}
|
|
onChange={(v) => patch(it.metric, { intervalNum: v ?? 1 })}
|
|
style={{ width: 80 }}
|
|
/>
|
|
<Select
|
|
value={e.intervalUnit}
|
|
style={{ width: 90 }}
|
|
onChange={(unit) => patch(it.metric, { intervalUnit: unit as IntervalUnit })}
|
|
options={[
|
|
{ value: 'minute', label: '分钟' },
|
|
{ value: 'hour', label: '小时' },
|
|
{ value: 'day', label: '天' },
|
|
]}
|
|
/>
|
|
</Space>
|
|
)}
|
|
<Space wrap>
|
|
<span>更新时间:</span>
|
|
<InputNumber
|
|
value={e.anchorHour}
|
|
min={0}
|
|
max={23}
|
|
onChange={(v) => patch(it.metric, { anchorHour: v ?? 0 })}
|
|
style={{ width: 64 }}
|
|
/>
|
|
<span>时</span>
|
|
<InputNumber
|
|
value={e.anchorMinute}
|
|
min={0}
|
|
max={59}
|
|
onChange={(v) => patch(it.metric, { anchorMinute: v ?? 0 })}
|
|
style={{ width: 64 }}
|
|
/>
|
|
<span>分</span>
|
|
<Tooltip title="到北京时间这个钟点(按间隔重复)刷新一次。按天=每日该时刻;按时/分=该时刻在周期内的相位。">
|
|
<span style={{ color: '#999', cursor: 'help' }}>ⓘ</span>
|
|
</Tooltip>
|
|
</Space>
|
|
|
|
{e.mode === 'random' && (
|
|
<div>
|
|
<Space wrap>
|
|
<span>初始基数:</span>
|
|
<InputNumber
|
|
value={e.initial}
|
|
min={0}
|
|
onChange={(v) => patch(it.metric, { initial: v })}
|
|
style={{ width: 200 }}
|
|
/>
|
|
<span style={{ color: '#666' }}>{u}</span>
|
|
<Tooltip title="填值=把展示值设为该数作为增长起点(受『只增不减』限制:低于当前展示值需先勾「允许数字下降」)。留空=保持现有展示值不变、继续按增量增长;仅当该指标从未启用过(还没有展示值)时,留空才用真实值播种。">
|
|
<span style={{ color: '#999', cursor: 'help' }}>ⓘ</span>
|
|
</Tooltip>
|
|
</Space>
|
|
{initialBlocked && (
|
|
<div style={{ color: '#fa8c16', fontSize: 12, marginTop: 4 }}>
|
|
低于当前展示值,默认不下调(如需下调请勾「允许数字下降」)
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<Checkbox
|
|
checked={e.allowDecrease}
|
|
onChange={(ev) => patch(it.metric, { allowDecrease: ev.target.checked })}
|
|
>
|
|
允许数字下降{' '}
|
|
<span style={{ color: '#999' }}>(默认关闭 = 只增不减,防门面缩水)</span>
|
|
</Checkbox>
|
|
|
|
<Space direction="vertical" size={2} style={{ color: '#888', fontSize: 12 }}>
|
|
<Space wrap size="large">
|
|
<span>
|
|
当前展示值:
|
|
{it.random_current == null ? '未启动' : fmtDisp(it.metric, it.random_current, it.unit)}
|
|
</span>
|
|
{it.updated_at && (
|
|
<span style={{ color: '#bbb' }}>
|
|
更新于 {formatUtcTime(it.updated_at, 'YYYY-MM-DD HH:mm:ss')}
|
|
</span>
|
|
)}
|
|
</Space>
|
|
{predictNext(it) && (
|
|
<span>
|
|
下次预计:{predictNext(it)}
|
|
<span style={{ color: '#bbb' }}>
|
|
{' '}(北京 {nextRefreshLabel(it.random_tick_seconds, it.random_anchor_minutes)} 刷新)
|
|
</span>
|
|
</span>
|
|
)}
|
|
</Space>
|
|
|
|
<Tag color={it.mode === 'real' ? 'green' : it.mode === 'manual' ? 'orange' : 'blue'}>
|
|
线上当前:
|
|
{it.mode === 'real' ? '真实值' : it.mode === 'manual' ? '自定义' : '自增长'}
|
|
</Tag>
|
|
</Space>
|
|
</Card>
|
|
</Col>
|
|
);
|
|
})}
|
|
</Row>
|
|
)}
|
|
{!loading && (
|
|
<div style={{ marginTop: 16 }}>
|
|
<Space>
|
|
<Button type="primary" loading={saving != null} onClick={() => saveAll(false)}>
|
|
保存
|
|
</Button>
|
|
<Popconfirm
|
|
title="立即更新会马上把三项都推进一次(自增长各走一档),不等更新时间。确定?"
|
|
onConfirm={() => saveAll(true)}
|
|
>
|
|
<Button loading={saving != null}>立即更新</Button>
|
|
</Popconfirm>
|
|
<span style={{ color: '#999', fontSize: 12 }}>三项配置共用,一次保存全部生效</span>
|
|
</Space>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|