打开详情就自动拉 GET /tasks/{id}/cost(不再只靠
“查成本”按钮出 JSON),详情顶部新一行:美元、总
token(实际/估算、缓存、无价单列)、轮数、fallback
次数;美元小于 1 美分时用 4 位小数,避免小任务
显示成 $0.00 误导成免费;加载中/失败有各自的
弱化文案,成本拉失败不影响详情其它内容;原
“查成本”按钮不动,仍可手动拉原始 JSON。
服务端不用改:成本接口与 _get_task 的 token 聚合
都已在 #150 / 详情 meta 那单里就位。
93 lines
4.5 KiB
TypeScript
93 lines
4.5 KiB
TypeScript
import type { Task } from "./types";
|
||
|
||
// 任务 meta 计算,列表行与详情共用,口径必须一致,改一处两处同变。
|
||
//
|
||
// 首字:prompt 第一个非空白字符(按码点取,emoji 不截半);
|
||
// prompt 为空或全空白时用任务 id 首字符兜底。只作标识,
|
||
// 不改动原 prompt 文案。
|
||
//
|
||
// 总耗时:只用任务对象已回的时间戳算,绝不编造。
|
||
// 终态且有 started_at + finished_at:finished_at - started_at
|
||
// (与服务端项目统计 avg_duration_seconds 同口径);
|
||
// running 且有 started_at:现在 - started_at,标“进行中”,
|
||
// 数值随刷新更新,不是实时 tick;
|
||
// 无 started_at 的终态:finished_at - created_at 兜底
|
||
// (含排队等待,口径见 title);
|
||
// 无 started_at 的 queued/running:现在 - created_at 兜底。
|
||
// 诚实边界:worker 每次认领都会重写 started_at,所以多轮续跑
|
||
// 任务这里显示的是末次认领的时长,不是历次认领之和;要严格
|
||
// 累计总时长,服务端需保留首次 started_at 或累加各轮时长,
|
||
// 那是服务端字段缺口,不是前端能算出来的。
|
||
//
|
||
// token:优先 total_tokens(列表接口由服务端一次聚合回,
|
||
// 单任务接口同样聚合回);分项只有 actual/estimated 时加总
|
||
// 显示;都没有时显示“token —”,不编造、不估算、不逐个调
|
||
// /cost(列表一屏 N 个任务逐个调会拖慢刷新、还容易撞限流)。
|
||
export function firstChar(t: Task): string {
|
||
const p = (t.prompt || "").trim();
|
||
if (p) return [...p][0];
|
||
return t.id ? [...t.id][0] : "?";
|
||
}
|
||
|
||
export function formatDuration(seconds: number): string {
|
||
const s = Math.max(0, Math.floor(seconds));
|
||
if (s < 60) return s + "秒";
|
||
if (s < 3600) {
|
||
const m = Math.floor(s / 60), r = s % 60;
|
||
return r ? m + "分" + r + "秒" : m + "分";
|
||
}
|
||
if (s < 86400) {
|
||
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60);
|
||
return m ? h + "小时" + m + "分" : h + "小时";
|
||
}
|
||
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600);
|
||
return h ? d + "天" + h + "小时" : d + "天";
|
||
}
|
||
|
||
function num(v: number | null | undefined): number | null {
|
||
if (v === null || v === undefined) return null;
|
||
const n = Number(v);
|
||
return Number.isFinite(n) ? n : null;
|
||
}
|
||
|
||
// 返回 [显示文案, title 口径说明];算不出时文案为“—”。
|
||
export function durationOf(t: Task): [string, string] {
|
||
const now = Date.now() / 1000;
|
||
const started = num(t.started_at), finished = num(t.finished_at);
|
||
const created = num(t.created_at);
|
||
if (started !== null && finished !== null && finished >= started) {
|
||
return [formatDuration(finished - started), "总耗时:开始→结束(末次认领的执行时长)"];
|
||
}
|
||
if (started !== null && t.status === "running") {
|
||
return ["进行中 " + formatDuration(now - started), "总耗时:进行中,已用时长从开始算起,随刷新更新"];
|
||
}
|
||
if (created !== null && finished !== null && finished >= created) {
|
||
return [formatDuration(finished - created), "总耗时:创建→完成(含排队等待;无开始时间时的兜底口径)"];
|
||
}
|
||
if (created !== null && (t.status === "queued" || t.status === "running")) {
|
||
return [(t.status === "queued" ? "排队中 " : "进行中 ") + formatDuration(now - created), "总耗时:从创建算起的已用时长(无开始时间时的兜底口径)"];
|
||
}
|
||
return ["—", "总耗时:无可用时间戳"];
|
||
}
|
||
|
||
// 成本美元:usd_micros 是微美元(1 美元 = 1,000,000)。
|
||
// 小于 1 美分时用 4 位小数,否则 2 位——小任务直接 toFixed(2)
|
||
// 会把 $0.003 显示成 $0.00,误导成免费。
|
||
export function fmtCostUsd(micros: number | null | undefined): string {
|
||
if (micros === null || micros === undefined || !Number.isFinite(Number(micros))) return "—";
|
||
const v = Number(micros) / 1000000;
|
||
return "$" + (v > 0 && v < 0.01 ? v.toFixed(4) : v.toFixed(2));
|
||
}
|
||
|
||
export function tokenOf(t: Task): [string, string] {
|
||
const total = num(t.total_tokens);
|
||
if (total !== null) {
|
||
return [total.toLocaleString() + " token", "token:服务端 total_tokens"];
|
||
}
|
||
const actual = num(t.actual_tokens), estimated = num(t.estimated_tokens);
|
||
if (actual !== null || estimated !== null) {
|
||
return [((actual || 0) + (estimated || 0)).toLocaleString() + " token", "token:actual + estimated 分项加总"];
|
||
}
|
||
return ["token —", "token:本任务对象未带 token 字段;单任务真数在详情的成本里可查,不逐个调成本接口、不编造"];
|
||
}
|