/* DCTS 格式化与转义工具(权威定义)
*
* 集中所有纯函数:HTML 转义、时间/时长格式化、状态徽章。
* 历史上 escapeHtml 定义在 api.js、各 fmt* 散落在 workflowDetail.js,改一处要全局搜,
* 现统一到本模块。api.js 通过 re-export 保持向后兼容。
*
* SQLite 约定:datetime('now') 形如 'YYYY-MM-DD HH:MM:SS'(UTC,无时区标记),
* 解析前需规范化为 ISO。所有时间解析函数共用 parseIso/parseTsMs。
*
* 语义与后端契约对齐(crates/server/src/db.rs):
* - 网格点状态:pending/queued/running/converged/failed/canceled
* - success_method(收敛途径):cold_run / seed_step
*/
/**
* 安全 HTML 转义,防止存储型 XSS。
* 顺序敏感:& 必须先转义,否则后续转义产生的 & 会被二次转义。
* @param {unknown} s
* @returns {string}
*/
export function escapeHtml(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
/** 规范化为 Date(解析失败返回 null)。SQLite 'YYYY-MM-DD HH:MM:SS' 视为 UTC。 */
export function parseIso(iso) {
if (!iso) return null;
const normalized = iso.includes('T') ? iso : iso.replace(' ', 'T') + 'Z';
const d = new Date(normalized);
return Number.isNaN(d.getTime()) ? null : d;
}
/** 同 parseIso 但返回 epoch 毫秒(解析失败为 NaN)。供进度曲线按真实时间铺横轴。 */
export function parseTsMs(iso) {
if (!iso) return NaN;
const normalized = iso.includes('T') ? iso : iso.replace(' ', 'T') + 'Z';
return new Date(normalized).getTime();
}
/** SQLite datetime('now') 为 'YYYY-MM-DD HH:MM:SS'(UTC 无时区标记),规范化后解析。 */
export function fmtTime(iso) {
if (!iso) return '—';
const normalized = iso.includes('T') ? iso : iso.replace(' ', 'T') + 'Z';
const d = new Date(normalized);
if (Number.isNaN(d.getTime())) return escapeHtml(iso);
return d.toLocaleTimeString('zh-CN');
}
/** 紧凑日期时间「MM-DD HH:MM」:带日期(跨天运行不至于看不出是哪天完成的),
* 隐去年份与秒(活跃网格数据不跨年,秒级精度放 title)。定宽 11 字符,列内对齐。 */
export function fmtDateTime(iso) {
const d = parseIso(iso);
if (!d) return iso ? escapeHtml(iso) : '—';
const p = (n) => String(n).padStart(2, '0');
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
/** 完整时间戳「YYYY-MM-DD HH:MM:SS」,供单元格 title 悬停查看精确时刻。 */
export function fmtFullTs(iso) {
const d = parseIso(iso);
if (!d) return iso || '';
const p = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
/** 工作流级时长格式化:「N 分钟」或「N 小时 M 分」(控制条 ETA 用)。null/非有限值/≤0 返回占位符。 */
export function fmtDuration(sec) {
// ≤0 视为无数据(与 fmtDur 同口径):失败任务上报的 last_elapsed_sec 可能是 0.0
// (runner 无 summary 时),显示"0 分钟"会误导,应展示占位符。
if (sec == null || !Number.isFinite(sec) || Number(sec) <= 0) return '—';
const m = Math.round(sec / 60);
if (m < 60) return `${m} 分钟`;
const h = Math.floor(m / 60);
return `${h} 小时 ${m % 60} 分`;
}
/** 网格点级紧凑时长(表格单元格用):42s / 12m 30s / 1h 20m。
* ≤0 视为无数据(tlusty 单点不可能 0 秒完成)。 */
export function fmtDur(sec) {
if (sec == null || sec === '') return '—';
const n = Number(sec);
if (!Number.isFinite(n) || n <= 0) return '—';
const s = Math.round(n);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m${s % 60 ? ` ${s % 60}s` : ''}`;
const h = Math.floor(m / 60);
return `${h}h${m % 60 ? ` ${m % 60}m` : ''}`;
}
/** 相对收敛度指数记数(点表/尝试表 max_relc 单元格)。空值/非法值返回占位符。 */
export function fmtRelc(v) {
if (v == null || v === '') return '—';
const n = Number(v);
return Number.isFinite(n) ? n.toExponential(2) : '—';
}
/** CSV 字段转义:含逗号/引号/换行时用双引号包裹并翻倍内部引号。 */
export function csvEscape(v) {
const s = String(v ?? '');
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}
/** 心跳时间容错:字段缺失或非法时间戳一律显示占位符,避免渲染出 "Invalid Date"。 */
export function fmtClock(ts) {
if (!ts) return '—';
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return '—';
return d.toLocaleTimeString('zh-CN');
}
// ===== 状态徽章(返回 HTML 字符串,供 innerHTML 注入) =====
/** 工作流状态映射:[中文标签, badge class]。后端可发 idle/initializing/running/paused/completed。 */
export const STATUS_MAP = {
running: ['运行中', 'online'],
completed: ['已完成', 'completed'],
initializing: ['初始化中', 'warning'],
paused: ['已暂停', 'secondary'],
idle: ['闲置', 'secondary'],
};
/** 工作流状态徽章 HTML。 */
export function statusBadge(status) {
const [cn, cls] = STATUS_MAP[status] || ['闲置', 'secondary'];
return `${cn}`;
}
/** 网格点状态映射:[中文标签, badge class]。与后端 grid_points.status 对齐。 */
export const POINT_STATUS_MAP = {
converged: ['已收敛', 'online'],
failed: ['失败', 'danger'],
running: ['运行中', 'warning'],
queued: ['排队中', 'info'],
pending: ['未开始', 'secondary'],
};
/** 网格点状态徽章 HTML。 */
export function pointStatusBadge(status) {
const [cn, cls] = POINT_STATUS_MAP[status] || ['未知', 'secondary'];
return `${cn}`;
}
/** 网格点收敛途径徽章 HTML(success_method:cold_run / seed_step / 策略名)。 */
export function pointMethodBadge(method) {
if (method === 'cold_run') return '冷启动';
if (method === 'seed_step') return '种子步进';
// 其它策略名(如 synspec-only 任务归因的 "standard")原样展示,让光谱归因可见。
if (method) return `${escapeHtml(method)}`;
return '—';
}