()
return rows.map((entry, index) => {
const base = rowKey(entry) || `i${index}`
let key = base
let n = 1
while (seen.has(key)) key = `${base}#${n++}`
seen.add(key)
return key
})
}
/* ─── 单元格渲染 ───────────────────────────── */
/**
* 进行中行的「首响」次行:**有值才渲染**(空串 = 首帧还没到,整行省掉)。与收尾行的写法只差
* 这一层判断:那边缺失等于「全程没有帧到达」= 失败,用「-」兜住;进行中的缺失只是「还没到」。
*/
function runningFirstLine(entry: RequestEntry): React.ReactNode {
const value = Number(entry.firstResponseMs)
if (!Number.isFinite(value) || value <= 0) return null
return (
{t('首响 {time}', { time: formatDuration(value) })}
)
}
/**
* 一个数据单元格(网格项,带 .req-xxx 列类名与 ta-* 对齐类)。
*
* 按列 key 建表而不是在行内联九个分支:行只回答「按哪些列、什么顺序」,「某一格长什么样」只有
* 一处实现 —— 列设置重排时才不会各画一个样。类名与旧实现的产出逐字一致(CSS 与窄窗口重排都按
* 这些类名走),空态占位的类名也照旧(重试列空时只有 .req-none,没有 .req-retry)。
*/
export function RequestCell({ entry, column, namesHidden }: { entry: RequestEntry; column: VisibleColumn; namesHidden: boolean }): React.ReactNode {
const { align, key } = column
const base = column.sel.slice(1) + (key === 'dur' || key === 'speed' ? ' req-num' : '')
const className = `${base} ta-${align}`
switch (key) {
case 'time': {
if (!entry.ts) return —
const d = new Date(entry.ts)
const pad = (n: number) => String(n).padStart(2, '0')
// 明细跨天(最多 30 天),日期与时刻分两行,月日必不可少
return (
{`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`}
{`${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`}
)
}
case 'target': {
const id = String(entry.provider ?? '').trim()
const fromBackend = String(entry.providerLabel ?? '').trim()
// 后端给的是注册名(「WorkBuddy 国内版」这类),与目录侧一样要过一遍词典
const provider = fromBackend ? t(fromBackend) : (id ? providerLabelOf(id) : '')
// 账号为空 = 请求在选定账号之前就失败了(后端契约:无账号时给空串),显式给破折号而不是留空
return (
{provider ? (
{provider}
) : (
—
)}
{entry.accountName ? (
{namesHidden ? maskName(entry.accountName) : entry.accountName}
) : (
—
)}
)
}
case 'retry': {
const attempts = Number(entry.attempts) || 1
const identity = rowKey(entry)
const tags: React.ReactNode[] = []
// 「测试」标记:这一行是模型管理页操作列那颗「测试」发起的(后端 `is_test`)。它与另外两枚
// 标签不同 —— **不是**悬停面板的锚点,只是一枚事实标签(brand 档,与模型表的「远程」同义),
// 所以不用 render 成 button。它进标签列而不是另开一列:它回答的正是「这一行是什么性质」,
// 与「重试 / 敏」同一类读数,而这张表的列是列设置里可拖的固定集合,为一个布尔值加一列不划算
if (entry.isTest) {
tags.push(
{clientRequestLabel(entry)},
)
}
// 判据交给悬停面板模块(它要读明细内部字段);兜底成 attempts > 1:模块没就绪时至少换过
// 号的请求仍能显示标签,而不是整列静默变空
const showChain = shared().wbRequestHover?.hasProcessFacts?.(entry) ?? attempts > 1
if (showChain) {
const count = attempts > 1 ? attempts : countRetries(entry)
tags.push(
// Badge 借 render 渲染成**真 button**:悬停面板的委托按 `[data-req-hover]` 找锚点、
// 键盘可达靠 focusin 委托,span 两样都给不了。事件与 data-* 由 Base UI 合并到这颗
// button 上(不是覆盖),观感则回到组件库的语义档(原来手写的 `warn` 即 warning)
}
className='req-hover-tag' data-req-hover='chain' data-req-id={identity}>
{count > 1 ? t('重试 {n}', { n: count }) : t('重试')}
,
)
}
// 敏感词命中:判据是「命中表非空」。标签只写一个「敏」字(这一列只有 52px,三个字会把
// 标签撑得比「重试 N」还宽);完整含义交给悬停面板,aria-label 补上完整说法。
// 紫色档(sensitive)是这次给组件库补的:它既不是「失败」也不是「国际版」,
// 是第三类事实;详情弹窗的展开形态读的是同一档,两处不会再有色差
if (Array.isArray(entry.sensitiveHits) && entry.sensitiveHits.length) {
tags.push(
}
className='req-hover-tag' data-req-hover='sensitive' data-req-id={identity}
aria-label={t('命中了敏感词')}>{t('敏')},
)
}
if (!tags.length) return -
return {tags}
}
case 'status': {
if (isRunning(entry)) {
const phase = window.wbRequestPhase
// 阶段模块没就绪(加载顺序是硬要求,但真出问题时这一格不该空着):回落成改造前的静态徽章
if (!phase?.badgeHtml) {
return (
{t('进行中')}
)
}
const elapsed = phase.elapsedLineHtml?.(entry) ?? ''
return (
{/* 徽章与阶段计时都由 wbRequestPhase 产出 HTML(详情弹窗读同一份,两处必须逐字一致) */}
} />
{elapsed ? } /> : null}
)
}
const status = Number(entry.status) || 0
const ok = isOk(entry)
// 走到这里 status=0 的只剩「还没发出请求就失败」的历史行:写 0 会被读成 HTTP 状态码,退成「失败」
const title = !ok && status >= 200 && status < 300
? t('HTTP {status},但响应体阶段出错:见错误列', { status })
: undefined
return (
{status || t('失败')}
)
}
case 'model': {
const client = String(entry.clientModel ?? '').trim()
const upstream = String(entry.upstreamModel ?? '').trim()
const shown = String(entry.model ?? '').trim()
const clientLevel = String(entry.clientReasoning ?? '').trim()
const upstreamLevel = String(entry.upstreamReasoning ?? '').trim()
const tag = (name: string, level: string) => (level ? `${name}(${level})` : name)
// 转发名与请求名一致(绝大多数请求)→ 单行;不一致(映射 / 备援改写发生过)→ 两行
if (!client || !upstream || upstream.toLowerCase() === client.toLowerCase()) {
const text = tag(shown, upstreamLevel || clientLevel)
return {text || '—'}
}
const upText = tag(upstream, upstreamLevel)
const downText = tag(client, clientLevel)
return (
⬆️ {upText}
⬇️ {downText}
)
}
case 'client': {
const label = clientRequestLabel(entry)
return {label}
}
case 'apiKey': {
const label = apiKeyRequestLabel(entry)
return {label}
}
case 'dur': {
if (isRunning(entry)) {
// 进行中行没有 durationMs(收尾才记账):主行显示已用时(每拍轮询在走)
return (
{formatElapsed(entry.ts)}
{runningFirstLine(entry)}
)
}
return (
{formatDuration(entry.durationMs)}
{t('首响 {time}', { time: formatFirstResponse(entry.firstResponseMs) })}
)
}
case 'speed': {
if (isRunning(entry)) return
const speed = isOk(entry) ? tokensPerSecond(entry) : ''
return {speed || '—'}
}
case 'credit': {
// 进行中:积分要等上游最后一个 chunk 的 usage 才到,此刻没有读数可给
// (与「用量」列同一处置:留空比「-」更准确)。
if (isRunning(entry)) return
// 失败请求不记录用量(后端 normalize 已把 credit 清成 None):这里与
// 「用量」列用同一句说明,避免把「失败清零」误读成「这一家没上报」。
if (!isOk(entry) && visionDetail(entry.route)?.mode !== 'assist_call') {
return —
}
const value = entry.credit
if (value === null || value === undefined || !Number.isFinite(Number(value))) {
return (
—
)
}
return (
{numberText(value)}
)
}
case 'usage': {
// 进行中:用量要等收尾才记账,此刻没有任何读数可给 —— 留空比「-」更准确
if (isRunning(entry)) return
// 失败请求的 token 由后端一律清零,写「0」会让人以为真的消耗了这些量
if (!isOk(entry) && visionDetail(entry.route)?.mode !== 'assist_call') {
return (
in: - / out: - / all: -
{t('缓存读取: - / 命中率: -')}
)
}
const line1 = `in: ${formatTokens(entry.promptTokens)} / out: ${formatTokens(entry.completionTokens)} / all: ${formatTokens(entry.totalTokens)}`
const line2 = t('缓存读取: {cache} / 命中率: {rate}', {
cache: formatTokens(entry.cacheReadTokens), rate: cacheRate(entry),
})
return (
{line1}
{line2}
)
}
case 'error': {
// 进行中行例外:错误还没有发生,留空 —— 「-」会说成「没出错」,留空才是「还没到有错误的时刻」
if (isRunning(entry)) return
if (entry.error) {
return {String(entry.error)}
}
return -
}
case 'detail': {
const id = entry.id ? String(entry.id) : ''
if (!id && !entry.clientEvidence) return -
return (
)
}
default:
return null
}
}
/** 表头:按可见列逐格产出,顺序与数据行逐格一致(两处都走 visibleColumns) */
export function headRow(columns: VisibleColumn[], namesHidden: boolean): React.ReactNode {
return (
{columns.map(column => (
{column.key === 'target' ? (
{column.label}
) : column.label}
))}
)
}