feat(channels): add gateway runtime status

This commit is contained in:
晴天
2026-05-23 00:09:46 +08:00
parent 718efe7e33
commit 1ae223a0b1
4 changed files with 568 additions and 1 deletions

170
src/lib/channel-runtime.js Normal file
View File

@@ -0,0 +1,170 @@
const EMPTY_STATUS = Object.freeze({
supported: false,
ts: 0,
partial: false,
warnings: Object.freeze([]),
channelOrder: Object.freeze([]),
channelLabels: Object.freeze({}),
channelDetailLabels: Object.freeze({}),
channelAccounts: Object.freeze({}),
channelDefaultAccountId: Object.freeze({}),
channels: Object.freeze({}),
eventLoop: null,
})
export function normalizeChannelRuntimeStatus(raw) {
if (!raw || typeof raw !== 'object') {
return { ...EMPTY_STATUS }
}
return {
supported: true,
ts: Number.isFinite(raw.ts) ? raw.ts : Date.now(),
partial: raw.partial === true,
warnings: Array.isArray(raw.warnings) ? raw.warnings.filter(Boolean).map(String) : [],
channelOrder: Array.isArray(raw.channelOrder) ? raw.channelOrder.filter(Boolean).map(String) : [],
channelLabels: plainObject(raw.channelLabels),
channelDetailLabels: plainObject(raw.channelDetailLabels),
channelAccounts: normalizeChannelAccounts(raw.channelAccounts),
channelDefaultAccountId: plainObject(raw.channelDefaultAccountId),
channels: plainObject(raw.channels),
eventLoop: raw.eventLoop && typeof raw.eventLoop === 'object' ? raw.eventLoop : null,
}
}
export function getChannelRuntimeSummary(status, channelId, fallbackLabel = '') {
const normalized = status && typeof status === 'object' && Object.prototype.hasOwnProperty.call(status, 'supported')
? status
: normalizeChannelRuntimeStatus(status)
const channel = String(channelId || '')
const accounts = normalizeChannelAccounts(normalized.channelAccounts)[channel] || []
const counts = countAccountStates(accounts)
const supported = normalized.supported === true
const state = supported ? pickSummaryState({ accounts, counts }) : 'unsupported'
return {
supported,
channel,
label: normalized.channelLabels?.[channel] || fallbackLabel || channel,
detailLabel: normalized.channelDetailLabels?.[channel] || '',
defaultAccountId: normalized.channelDefaultAccountId?.[channel] || '',
rawChannel: normalized.channels?.[channel] || null,
accounts,
counts,
state,
lastError: firstAccountValue(accounts, 'lastError'),
lastInboundAt: latestNumber(accounts, 'lastInboundAt'),
lastOutboundAt: latestNumber(accounts, 'lastOutboundAt'),
lastTransportActivityAt: latestNumber(accounts, 'lastTransportActivityAt'),
}
}
export function getRuntimeStateMeta(state) {
switch (state) {
case 'error':
return { label: '异常', tone: 'error', icon: 'alert-triangle' }
case 'connected':
return { label: '已连接', tone: 'success', icon: 'check' }
case 'running':
return { label: '运行中', tone: 'accent', icon: 'play' }
case 'configured':
return { label: '已配置', tone: 'warning', icon: 'gear' }
case 'disabled':
return { label: '已停用', tone: 'muted', icon: 'pause' }
case 'missing':
return { label: '未配置', tone: 'muted', icon: 'circle' }
case 'unsupported':
return { label: '内核不支持', tone: 'warning', icon: 'alert-triangle' }
default:
return { label: '未知', tone: 'muted', icon: 'info' }
}
}
export function formatRuntimeAge(value, now = Date.now()) {
const ts = Number(value)
if (!Number.isFinite(ts) || ts <= 0) return ''
const delta = Math.max(0, Number(now) - ts)
const seconds = Math.floor(delta / 1000)
if (seconds < 45) return '刚刚'
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes} 分钟前`
const hours = Math.floor(minutes / 60)
if (hours < 48) return `${hours} 小时前`
const days = Math.floor(hours / 24)
return `${days} 天前`
}
function normalizeChannelAccounts(value) {
const result = {}
if (!value || typeof value !== 'object' || Array.isArray(value)) return result
for (const [channel, accounts] of Object.entries(value)) {
if (!Array.isArray(accounts)) continue
result[channel] = accounts
.filter(account => account && typeof account === 'object')
.map(account => {
const accountId = account.accountId == null || account.accountId === '' ? 'default' : String(account.accountId)
return {
...account,
accountId,
state: pickAccountState(account),
lastError: account.lastError ? String(account.lastError) : '',
}
})
}
return result
}
function plainObject(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
return { ...value }
}
function pickAccountState(account) {
if (account.lastError) return 'error'
if (account.enabled === false) return 'disabled'
if (account.connected === true || account.linked === true) return 'connected'
if (account.running === true) return 'running'
if (account.configured === true) return 'configured'
return 'missing'
}
function pickSummaryState({ accounts, counts }) {
if (!accounts.length) return 'missing'
if (counts.error > 0) return 'error'
if (counts.connected > 0) return 'connected'
if (counts.running > 0) return 'running'
if (counts.configured > 0) return 'configured'
if (counts.disabled > 0) return 'disabled'
return 'missing'
}
function countAccountStates(accounts) {
const counts = {
total: accounts.length,
error: 0,
connected: 0,
running: 0,
configured: 0,
disabled: 0,
missing: 0,
}
for (const account of accounts) {
const state = account.state || pickAccountState(account)
counts[state] = (counts[state] || 0) + 1
}
return counts
}
function latestNumber(accounts, key) {
let latest = 0
for (const account of accounts) {
const value = Number(account?.[key])
if (Number.isFinite(value) && value > latest) latest = value
}
return latest || null
}
function firstAccountValue(accounts, key) {
const found = accounts.find(account => account?.[key])
return found?.[key] ? String(found[key]) : ''
}

View File

@@ -11,6 +11,12 @@ import { CHANNEL_LABELS } from '../lib/channel-labels.js'
import { t } from '../lib/i18n.js'
import { termHelpHtml, attachTermTooltips } from '../lib/term-tooltip.js'
import { wsClient } from '../lib/ws-client.js'
import {
formatRuntimeAge,
getChannelRuntimeSummary,
getRuntimeStateMeta,
normalizeChannelRuntimeStatus,
} from '../lib/channel-runtime.js'
// ── 渠道注册表:面板内置向导,覆盖 OpenClaw 官方渠道 + 国内扩展渠道 ──
@@ -301,6 +307,8 @@ export async function render() {
configured: [],
bindings: [],
agents: [],
runtimeStatus: normalizeChannelRuntimeStatus(null),
runtimeStatusError: '',
routeIntent: parseChannelsRouteIntent(),
routeIntentConsumed: false,
routeIntentHintShown: false,
@@ -346,6 +354,26 @@ async function loadPlatforms(page, state) {
renderAvailable(page, state)
renderAgentBindings(page, state)
applyRouteIntent(page, state)
refreshChannelRuntimeStatus(page, state, { probe: true, timeoutMs: 5000 })
}
async function loadChannelRuntimeStatus(state, options = {}) {
state.runtimeStatusError = ''
try {
const raw = await wsClient.requestCompat('channels.status', {
probe: options.probe !== false,
timeoutMs: options.timeoutMs || 5000,
}, null)
state.runtimeStatus = normalizeChannelRuntimeStatus(raw)
} catch (e) {
state.runtimeStatus = normalizeChannelRuntimeStatus(null)
state.runtimeStatusError = e?.message || String(e)
}
}
async function refreshChannelRuntimeStatus(page, state, options = {}) {
await loadChannelRuntimeStatus(state, options)
renderConfigured(page, state)
}
function applyRouteIntent(page, state) {
@@ -390,6 +418,129 @@ function platformLabel(pid) {
return PLATFORM_REGISTRY[pid]?.label || CHANNEL_LABELS[pid] || pid
}
function renderRuntimeNotice(state) {
if (state.runtimeStatusError) {
return `
<div class="channel-runtime-notice error">
${icon('alert-triangle', 14)}
<span>无法读取 Gateway 渠道运行态:${escapeAttr(state.runtimeStatusError)}</span>
</div>
`
}
if (!state.runtimeStatus?.supported) {
return `
<div class="channel-runtime-notice warning">
${icon('alert-triangle', 14)}
<span>当前 OpenClaw 内核不支持通用渠道运行态,请升级到新版内核;配置编辑仍可继续使用。</span>
</div>
`
}
const warnings = Array.isArray(state.runtimeStatus.warnings) ? state.runtimeStatus.warnings : []
if (state.runtimeStatus.partial || warnings.length) {
return `
<div class="channel-runtime-notice warning">
${icon('alert-triangle', 14)}
<span>${state.runtimeStatus.partial ? '运行态结果不完整' : '运行态探测有提示'}${warnings.length ? `${escapeAttr(warnings.join(''))}` : ''}</span>
</div>
`
}
return ''
}
function renderRuntimeBadge(summary) {
if (!summary.supported) return ''
const meta = getRuntimeStateMeta(summary.state)
return `<span class="runtime-badge ${meta.tone}" title="Gateway 运行态">${icon(meta.icon, 12)} ${meta.label}</span>`
}
function renderRuntimeSummary(summary) {
if (!summary.supported) {
return `<div class="channel-runtime-summary muted">通用运行态不可用</div>`
}
const parts = []
if (summary.counts.total) parts.push(`${summary.counts.total} 个账号`)
if (summary.counts.connected) parts.push(`${summary.counts.connected} 已连接`)
if (summary.counts.running) parts.push(`${summary.counts.running} 运行中`)
if (summary.counts.error) parts.push(`${summary.counts.error} 异常`)
if (summary.lastInboundAt) parts.push(`最近接收 ${formatRuntimeAge(summary.lastInboundAt)}`)
if (summary.lastOutboundAt) parts.push(`最近发送 ${formatRuntimeAge(summary.lastOutboundAt)}`)
if (summary.lastError) parts.push(`错误:${summary.lastError}`)
return `<div class="channel-runtime-summary ${summary.state}">${escapeAttr(parts.join(' · ') || 'Gateway 暂未返回账号状态')}</div>`
}
function renderRuntimeAccountInfo(summary, accountId) {
if (!summary.supported) return ''
const normalizedAccountId = accountId || 'default'
const account = summary.accounts.find(a => (a.accountId || 'default') === normalizedAccountId)
|| (!accountId ? summary.accounts.find(a => a.accountId === summary.defaultAccountId) : null)
if (!account) return ''
const meta = getRuntimeStateMeta(account.state)
const details = []
if (account.lastError) details.push(account.lastError)
if (account.healthState) details.push(`health=${account.healthState}`)
if (account.lastInboundAt) details.push(`${formatRuntimeAge(account.lastInboundAt)}`)
if (account.lastOutboundAt) details.push(`${formatRuntimeAge(account.lastOutboundAt)}`)
if (account.probe && typeof account.probe === 'object' && account.probe.ok === false) details.push('探测失败')
return `
<span class="runtime-account ${meta.tone}" title="${escapeAttr(details.join(' · ') || meta.label)}">
${icon(meta.icon, 12)} ${meta.label}
</span>
`
}
function renderRuntimeActions(summary, accountId = '') {
if (!summary.supported) {
return `<button class="btn btn-sm btn-secondary" data-runtime-action="refresh">${icon('refresh-cw', 14)} 刷新状态</button>`
}
const accountAttr = escapeAttr(accountId || '')
const stopDisabled = summary.state === 'missing' || summary.state === 'configured'
const logoutDisabled = summary.state === 'missing'
return `
<button class="btn btn-sm btn-secondary" data-runtime-action="refresh" data-account-id="${accountAttr}" title="刷新 Gateway 渠道状态">${icon('refresh-cw', 14)} 刷新</button>
<button class="btn btn-sm btn-secondary" data-runtime-action="start" data-account-id="${accountAttr}" title="启动该渠道运行时">${icon('play', 14)} 启动</button>
<button class="btn btn-sm btn-secondary" data-runtime-action="stop" data-account-id="${accountAttr}" ${stopDisabled ? 'disabled' : ''} title="停止该渠道运行时">${icon('stop', 14)} 停止</button>
<button class="btn btn-sm btn-secondary" data-runtime-action="logout" data-account-id="${accountAttr}" ${logoutDisabled ? 'disabled' : ''} title="注销该渠道账号登录态">${icon('x-circle', 14)} 注销</button>
`
}
async function handleRuntimeAction(pid, action, accountId, btn, page, state) {
const channel = getChannelBindingKey(pid)
const prevHtml = btn?.innerHTML
if (btn) {
btn.disabled = true
btn.textContent = action === 'refresh' ? '刷新中' : '处理中'
}
try {
if (action === 'refresh') {
await loadChannelRuntimeStatus(state, { probe: true, timeoutMs: 7000 })
renderConfigured(page, state)
toast('渠道运行态已刷新', 'success')
return
}
if (!wsClient.connected || !wsClient.gatewayReady) {
throw new Error('Gateway WebSocket 未连接,请先启动 OpenClaw Gateway')
}
const params = { channel }
if (accountId) params.accountId = accountId
await wsClient.request(`channels.${action}`, params)
await loadChannelRuntimeStatus(state, { probe: true, timeoutMs: 7000 })
renderConfigured(page, state)
const actionText = action === 'start' ? '启动' : action === 'stop' ? '停止' : '注销'
toast(`${platformLabel(pid)} ${actionText}完成`, 'success')
} catch (e) {
state.runtimeStatusError = e?.message || String(e)
renderConfigured(page, state)
toast(humanizeError(e, '渠道运行时操作失败'), 'error')
} finally {
if (btn) {
btn.disabled = false
if (prevHtml != null) btn.innerHTML = prevHtml
}
}
}
function renderConfigured(page, state) {
const el = page.querySelector('#platforms-configured')
if (!state.configured.length) {
@@ -400,12 +551,14 @@ function renderConfigured(page, state) {
el.innerHTML = `
<div class="config-section">
<div class="config-section-title">${t('channels.configured')}</div>
${renderRuntimeNotice(state)}
<div class="platforms-grid">
${state.configured.map(p => {
const reg = PLATFORM_REGISTRY[p.id]
const label = platformLabel(p.id)
const ic = icon(reg?.iconName || 'radio', 22)
const channelKey = getChannelBindingKey(p.id)
const runtimeSummary = getChannelRuntimeSummary(state.runtimeStatus, channelKey, label)
const accounts = Array.isArray(p.accounts) ? p.accounts : []
const hasAccounts = accounts.length > 0
const supportsMulti = MULTI_INSTANCE_PLATFORMS.includes(p.id)
@@ -426,6 +579,7 @@ function renderConfigured(page, state) {
<span class="account-id">${escapeAttr(accId)}</span>
${acc.appId ? `<span class="account-appid">${escapeAttr(acc.appId)}</span>` : ''}
${badgesHtml}
${renderRuntimeAccountInfo(runtimeSummary, acc.accountId || '')}
<span class="account-actions">
<button class="btn btn-xs btn-secondary" data-action="edit-account" data-account-id="${escapeAttr(acc.accountId || '')}">${icon('edit', 12)} ${t('channels.editAccount')}</button>
<button class="btn btn-xs btn-danger" data-action="remove-account" data-account-id="${escapeAttr(acc.accountId || '')}">${icon('trash', 12)}</button>
@@ -440,10 +594,13 @@ function renderConfigured(page, state) {
<span class="platform-emoji">${ic}</span>
<span class="platform-name">${label}</span>
<span class="account-count">${t('channels.accountCount', { count: accounts.length })}</span>
${renderRuntimeBadge(runtimeSummary)}
<span class="platform-status-dot ${p.enabled ? 'on' : 'off'}"></span>
</div>
${renderRuntimeSummary(runtimeSummary)}
<div class="platform-accounts">${accountsHtml}</div>
<div class="platform-card-actions">
${renderRuntimeActions(runtimeSummary)}
${supportsMulti ? `<button class="btn btn-sm btn-secondary" data-action="add-account">${icon('plus', 14)} ${t('channels.addAccount')}</button>` : ''}
${reg ? `<button class="btn btn-sm btn-secondary" data-action="edit">${icon('edit', 14)} ${t('channels.editDefault')}</button>` : `<span class="form-hint" style="align-self:center">${t('channels.noGuide')}</span>`}
<button class="btn btn-sm btn-secondary" data-action="toggle">${p.enabled ? icon('pause', 14) + ' ' + t('channels.disable') : icon('play', 14) + ' ' + t('channels.enable')}</button>
@@ -465,9 +622,12 @@ function renderConfigured(page, state) {
<span class="platform-emoji">${ic}</span>
<span class="platform-name">${label}</span>
${agentBadges}
${renderRuntimeBadge(runtimeSummary)}
<span class="platform-status-dot ${p.enabled ? 'on' : 'off'}"></span>
</div>
${renderRuntimeSummary(runtimeSummary)}
<div class="platform-card-actions">
${renderRuntimeActions(runtimeSummary)}
${supportsMulti ? `<button class="btn btn-sm btn-secondary" data-action="add-account">${icon('plus', 14)} ${t('channels.addAccount')}</button>` : ''}
${reg ? `<button class="btn btn-sm btn-secondary" data-action="edit">${icon('edit', 14)} ${t('channels.editAccount')}</button>` : `<span class="form-hint" style="align-self:center">${t('channels.noGuide')}</span>`}
<button class="btn btn-sm btn-secondary" data-action="toggle">${p.enabled ? icon('pause', 14) + ' ' + t('channels.disable') : icon('play', 14) + ' ' + t('channels.enable')}</button>
@@ -691,6 +851,11 @@ function renderConfigured(page, state) {
card.querySelector('[data-action="add-account"]')?.addEventListener('click', () => openConfigDialog(pid, page, state, ''))
card.querySelector('[data-action="edit"]')?.addEventListener('click', () => openConfigDialog(pid, page, state))
card.querySelectorAll('[data-runtime-action]').forEach(btn => {
btn.addEventListener('click', () => {
handleRuntimeAction(pid, btn.dataset.runtimeAction, btn.dataset.accountId || '', btn, page, state)
})
})
card.querySelectorAll('[data-action="edit-account"]').forEach(btn => {
btn.addEventListener('click', () => {

View File

@@ -1005,6 +1005,7 @@
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-md);
flex-wrap: wrap;
}
.platform-emoji {
font-size: 22px;
@@ -1035,6 +1036,144 @@
flex-wrap: wrap;
}
.channel-runtime-notice {
display: flex;
align-items: flex-start;
gap: var(--space-sm);
padding: 10px 12px;
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
line-height: 1.55;
margin-bottom: var(--space-md);
}
.channel-runtime-notice.warning {
color: var(--warning);
background: var(--warning-muted);
border-color: rgba(217, 119, 6, 0.25);
}
.channel-runtime-notice.error {
color: var(--error);
background: var(--error-muted);
border-color: rgba(220, 38, 38, 0.25);
}
.runtime-badge,
.runtime-account {
display: inline-flex;
align-items: center;
gap: 4px;
min-height: 22px;
padding: 2px 7px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 600;
white-space: nowrap;
}
.runtime-badge.success,
.runtime-account.success {
color: var(--success);
background: var(--success-muted);
}
.runtime-badge.error,
.runtime-account.error {
color: var(--error);
background: var(--error-muted);
}
.runtime-badge.warning,
.runtime-account.warning {
color: var(--warning);
background: var(--warning-muted);
}
.runtime-badge.accent,
.runtime-account.accent {
color: var(--accent);
background: var(--accent-muted);
}
.runtime-badge.muted,
.runtime-account.muted {
color: var(--text-secondary);
background: var(--bg-tertiary);
}
.channel-runtime-summary {
margin: -4px 0 var(--space-md);
padding: 8px 10px;
border-radius: var(--radius-md);
background: var(--bg-tertiary);
color: var(--text-secondary);
font-size: var(--font-size-xs);
line-height: 1.55;
word-break: break-word;
}
.channel-runtime-summary.error {
color: var(--error);
background: var(--error-muted);
}
.channel-runtime-summary.connected {
color: var(--success);
background: var(--success-muted);
}
.channel-runtime-summary.running {
color: var(--accent);
background: var(--accent-muted);
}
.channel-runtime-summary.configured {
color: var(--warning);
background: var(--warning-muted);
}
.platform-accounts {
display: flex;
flex-direction: column;
gap: var(--space-xs);
margin-bottom: var(--space-md);
}
.account-item {
display: flex;
align-items: center;
gap: var(--space-xs);
flex-wrap: wrap;
padding: 8px 10px;
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
background: var(--bg-secondary);
}
.account-id {
font-family: var(--font-mono);
font-size: var(--font-size-xs);
font-weight: 700;
color: var(--text-primary);
}
.account-appid {
min-width: 0;
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--font-size-xs);
color: var(--text-tertiary);
}
.account-actions {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: auto;
}
@media (max-width: 640px) {
.platforms-grid {
grid-template-columns: 1fr;
}
.platform-card {
padding: var(--space-md);
}
.platform-card-actions .btn {
min-height: 36px;
}
.account-actions {
width: 100%;
margin-left: 0;
}
}
/* 可接入平台选择按钮 */
.platform-pick {
display: flex;
@@ -3147,4 +3286,4 @@
transition: none !important;
animation: none !important;
}
}
}

View File

@@ -0,0 +1,93 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
formatRuntimeAge,
getChannelRuntimeSummary,
normalizeChannelRuntimeStatus,
} from '../src/lib/channel-runtime.js'
test('normalizeChannelRuntimeStatus marks missing RPC result as unsupported', () => {
const status = normalizeChannelRuntimeStatus(null)
assert.equal(status.supported, false)
assert.deepEqual(status.channelOrder, [])
assert.deepEqual(status.channels, {})
})
test('getChannelRuntimeSummary preserves normalized unsupported status', () => {
const status = normalizeChannelRuntimeStatus(null)
const summary = getChannelRuntimeSummary(status, 'telegram', 'Telegram')
assert.equal(summary.supported, false)
assert.equal(summary.state, 'unsupported')
assert.equal(summary.label, 'Telegram')
assert.deepEqual(summary.accounts, [])
})
test('getChannelRuntimeSummary prefers account errors over connected state', () => {
const status = normalizeChannelRuntimeStatus({
ts: 1000,
channelOrder: ['telegram'],
channelLabels: { telegram: 'Telegram' },
channelAccounts: {
telegram: [
{
accountId: 'bot-a',
configured: true,
enabled: true,
running: true,
connected: true,
lastError: '401 Unauthorized',
},
],
},
channelDefaultAccountId: { telegram: 'bot-a' },
channels: {},
})
const summary = getChannelRuntimeSummary(status, 'telegram')
assert.equal(summary.supported, true)
assert.equal(summary.state, 'error')
assert.equal(summary.label, 'Telegram')
assert.equal(summary.defaultAccountId, 'bot-a')
assert.equal(summary.accounts[0].state, 'error')
assert.equal(summary.accounts[0].lastError, '401 Unauthorized')
})
test('getChannelRuntimeSummary counts account states and preserves unknown channel fields', () => {
const status = normalizeChannelRuntimeStatus({
ts: 2000,
partial: true,
warnings: ['probe timeout'],
channelOrder: ['slack'],
channelLabels: { slack: 'Slack' },
channelAccounts: {
slack: [
{ accountId: 'team-a', configured: true, running: true },
{ accountId: 'team-b', configured: true, connected: true, audit: { messages: 3 } },
{ accountId: 'team-c', enabled: false, configured: true },
],
},
channelDefaultAccountId: { slack: 'team-a' },
channels: { slack: { custom: 'value' } },
})
const summary = getChannelRuntimeSummary(status, 'slack')
assert.equal(status.supported, true)
assert.equal(status.partial, true)
assert.deepEqual(status.warnings, ['probe timeout'])
assert.deepEqual(status.channels.slack, { custom: 'value' })
assert.equal(summary.state, 'connected')
assert.equal(summary.counts.connected, 1)
assert.equal(summary.counts.running, 1)
assert.equal(summary.counts.disabled, 1)
})
test('formatRuntimeAge returns compact relative time labels', () => {
assert.equal(formatRuntimeAge(1_000, 61_000), '1 分钟前')
assert.equal(formatRuntimeAge(1_000, 3_601_000), '1 小时前')
assert.equal(formatRuntimeAge(1_000, 172_801_000), '2 天前')
})