Files
yunke/yunkes/src/utils/format.ts
T
浅念 8fde1d8597 fix: render chat media by URL (images/video/voice) instead of unknown-type placeholder
MessageBubble.vue was keys off the (incomplete) type code, so many messages
with real OSS media URLs (https://wx.oss.yunkecn.com/.../*.webp|*.mp4) showed
as '[未知消息]'. Rendering is now URL-driven: pick the best http URL from
file_url > file_hd_url > file_th_url, then render by extension
(image/video/audio/file). Type code is only used as a last-resort label.

- MessageBubble.vue: URL-driven cascade (URL by ext → duration →
  transcription → text → structured-type placeholder → localized tag).
  Image/video/audio/file children @click.stop so the all-fields drawer
  still opens on bubble click but not on media interactions.
- format.ts: wechatTypeLabel(type_raw) maps known codes (3→图片, 34→语音,
  43→图片/表情, 49→文件/名片, 42→名片, 9→表情, 20→红包/转账).
- ChatTimeline.vue drawer type tag uses wechatTypeLabel for WeChat msgs.
- style.css: compact .yk-msg__audio player for voice-with-URL messages.

Verified against smoke.db: 25 type-3 images + 3 type-43 videos that
previously showed '[未知消息]' now render as images/video. Build clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 10:23:01 +08:00

145 lines
4.9 KiB
TypeScript

/** Formatting helpers for chat timeline + customer list. */
/** ms-epoch → `YYYY-MM-DD HH:mm:ss` in local time. `--` for missing/invalid. */
export function formatTime(ts?: number | null): string {
if (!ts || !Number.isFinite(ts) || ts <= 0) return '--'
const d = new Date(ts)
if (Number.isNaN(d.getTime())) return '--'
const pad = (n: number) => String(n).padStart(2, '0')
return (
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
`${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
)
}
/** ms-epoch → short `MM-DD HH:mm` for dense timelines. */
export function formatTimeShort(ts?: number | null): string {
if (!ts || !Number.isFinite(ts) || ts <= 0) return ''
const d = new Date(ts)
if (Number.isNaN(d.getTime())) return ''
const pad = (n: number) => String(n).padStart(2, '0')
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
/**
* For voice messages the backend stores duration in seconds (best-effort).
* Render as `N"` or `M'SS"` to mirror the messaging-app look.
*/
export function formatDuration(seconds?: number | null): string {
if (!seconds || !Number.isFinite(seconds) || seconds <= 0) return ''
const s = Math.round(seconds)
if (s < 60) return `${s}"`
const m = Math.floor(s / 60)
const rem = s % 60
return `${m}'${String(rem).padStart(2, '0')}"`
}
/** Friendly label for a Channel value. */
export function channelLabel(channel?: string | null): string {
switch (channel) {
case 'wechat':
return '微信'
case 'whatsapp':
return 'WhatsApp'
default:
return channel ?? '未知'
}
}
// ---------------------------------------------------------------------------
// WeChat message type labels
// ---------------------------------------------------------------------------
/**
* 中文 label for the upstream WeChat `type_raw` numeric code. The label is
* best-effort — different upstream sources disagree on some codes (e.g. 43 is
* variously 图片/表情/视频). Rendering is URL-driven (see MessageBubble.vue),
* so this label only informs the unknown-fallback bubble and the message
* detail drawer tag — never the actual media display.
*
* WhatsApp type codes (0→文字, 1→图片) are intentionally NOT mapped here:
* the structured `type` field already classifies them, and WA media uses a
* `mediaName` filename rather than an http URL, so it can't render without the
* download endpoint and degrades to a placeholder either way.
*/
const WECHAT_TYPE_LABELS: Record<number, string> = {
3: '图片',
34: '语音',
42: '名片',
43: '图片/表情',
49: '文件/名片',
9: '表情',
20: '红包/转账',
}
/** 中文 label for a WeChat type_raw code; `未知(typeRaw)` when unknown. */
export function wechatTypeLabel(typeRaw?: number | null): string {
if (typeRaw == null) return '未知'
return WECHAT_TYPE_LABELS[typeRaw] ?? `未知(${typeRaw})`
}
// ---------------------------------------------------------------------------
// Phone / voice call rendering
// ---------------------------------------------------------------------------
/** Call-type (PhoneCall.channel) label — 电话 vs 语音通话. */
export function callTypeLabel(channel?: string | null): string {
switch (channel) {
case 'phone_call':
return '电话'
case 'voice_call':
return '语音通话'
default:
return channel ?? '未知'
}
}
/** Call direction. ``incoming_call``: 1 = 呼入 (incoming), 0 = 呼出 (outgoing). */
export function callDirectionLabel(incoming?: number | null): string {
if (incoming == null) return '--'
return incoming === 1 ? '呼入' : '呼出'
}
/**
* Call status. The upstream schema uses 1 = 已接通 (answered/connected) and
* 0 = 未接通 (missed/failed); other codes fall back to the raw value so the UI
* never hides information.
*/
export function callStatusLabel(status?: number | null): string {
if (status == null) return '--'
switch (status) {
case 1:
return '已接通'
case 0:
return '未接通'
default:
return `状态 ${status}`
}
}
/** Render call_duration (seconds) as `N"s` / `M'SS"`; ``--`` when missing. */
export function formatCallDuration(seconds?: number | null): string {
if (!seconds || !Number.isFinite(seconds) || seconds <= 0) return '--'
return formatDuration(seconds)
}
// ---------------------------------------------------------------------------
// SMS rendering
// ---------------------------------------------------------------------------
/**
* SMS direction. Conventions: ``direction`` 1 = 发送 (outbound, staff →
* customer), 0 = 接收 (inbound). Other values fall back to the raw code.
*/
export function smsDirectionLabel(direction?: number | null): string {
if (direction == null) return '--'
switch (direction) {
case 1:
return '发送'
case 0:
return '接收'
default:
return `方向 ${direction}`
}
}