feat: chat integration, tenant admin spec, backend chat service, miniprogram updates, DEMO moved to tmp, XCX-TEST removed, migrations & docs

This commit is contained in:
Neo
2026-03-20 09:02:10 +08:00
parent 3d2e5f8165
commit beb88d5bea
388 changed files with 6436 additions and 25458 deletions

View File

@@ -0,0 +1,126 @@
/**
* 时间展示工具
* 规范docs/miniprogram-dev/design-system/DATETIME-DISPLAY-STANDARD.md
*
* 规则(由近及远):
* < 120s → 刚刚
* 2min ~ 59min → N分钟前
* 1h ~ 23h → N小时前
* 1d ~ 3d → N天前
* > 3d 同年 → MM-DD
* > 3d 跨年 → YYYY-MM-DD
*/
/**
* 将时间戳或 ISO 字符串格式化为相对时间文案
* @param value Unix 毫秒时间戳 或 ISO 8601 字符串(如 "2026-03-10T16:30:00Z"
* @returns 格式化文案如「刚刚」「2分钟前」「03-10」
*/
/**
* 课时格式化
* 整数 → Nh非整数保留1位 → N.Nh零值 → 0h空值 → --
*/
export function formatHours(hours: number | null | undefined): string {
if (hours === null || hours === undefined) return '--'
if (hours === 0) return '0h'
return hours % 1 === 0 ? `${hours}h` : `${hours.toFixed(1)}h`
}
/**
* 任务截止日期语义化格式化
* 规范docs/miniprogram-dev/design-system/DISPLAY-STANDARDS-2.md §7
*
* @returns { text, style }
* style: 'muted'(灰) | 'normal'(正常) | 'warning'(橙/今天) | 'danger'(红/逾期)
*/
export function formatDeadline(
deadline: string | null | undefined,
): { text: string; style: 'normal' | 'warning' | 'danger' | 'muted' } {
if (!deadline) return { text: '--', style: 'muted' }
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const target = new Date(deadline)
const targetDay = new Date(target.getFullYear(), target.getMonth(), target.getDate())
const diff = Math.round((targetDay.getTime() - today.getTime()) / 86400000)
if (diff < 0) return { text: `逾期 ${Math.abs(diff)}`, style: 'danger' }
if (diff === 0) return { text: '今天到期', style: 'warning' }
if (diff <= 7) return { text: `还剩 ${diff}`, style: 'normal' }
const m = String(target.getMonth() + 1).padStart(2, '0')
const d = String(target.getDate()).padStart(2, '0')
return { text: `${m}-${d}`, style: 'muted' }
}
export function formatRelativeTime(value: number | string | undefined | null): string {
if (value === undefined || value === null || value === '') return '--'
const normalized = typeof value === 'string'
? value.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2})/, '$1T$2')
: value
const ts = typeof normalized === 'number' ? normalized : new Date(normalized).getTime()
if (isNaN(ts)) return '--'
const now = Date.now()
const diff = Math.floor((now - ts) / 1000) // 秒
// 未来时间(服务端时钟偏差)按「刚刚」处理
if (diff < 120) return '刚刚'
if (diff < 3600) return `${Math.floor(diff / 60)}分钟前`
if (diff < 86400) return `${Math.floor(diff / 3600)}小时前`
if (diff < 259200) return `${Math.floor(diff / 86400)}天前`
const date = new Date(ts)
const nowDate = new Date(now)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
if (year === nowDate.getFullYear()) return `${month}-${day}`
return `${year}-${month}-${day}`
}
/**
* IM 气泡内时间戳格式
* 今天内 → HH:mm
* 今年非今天 → MM-DD HH:mm
* 跨年 → YYYY-MM-DD HH:mm
*/
export function formatIMTime(value: number | string | undefined | null): string {
if (value === undefined || value === null || value === '') return ''
const normalized = typeof value === 'string'
? value.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2})/, '$1T$2')
: value
const ts = typeof normalized === 'number' ? normalized : new Date(normalized).getTime()
if (isNaN(ts)) return ''
const date = new Date(ts)
const now = new Date()
const hh = String(date.getHours()).padStart(2, '0')
const mn = String(date.getMinutes()).padStart(2, '0')
const timeStr = `${hh}:${mn}`
const isSameDay =
date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate()
if (isSameDay) return timeStr
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
if (date.getFullYear() === now.getFullYear()) return `${month}-${day} ${timeStr}`
return `${date.getFullYear()}-${month}-${day} ${timeStr}`
}
/**
* 判断相邻消息是否需要显示时间分割线(间隔 >= 5 分钟,首条始终显示)
*/
export function shouldShowTimeDivider(
prevTimestamp: number | string | null | undefined,
currTimestamp: number | string | undefined | null,
): boolean {
if (!prevTimestamp) return true
const normPrev = typeof prevTimestamp === 'string' ? prevTimestamp.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2})/, '$1T$2') : prevTimestamp
const normCurr = typeof currTimestamp === 'string' ? (currTimestamp as string).replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2})/, '$1T$2') : currTimestamp
const prev = typeof normPrev === 'number' ? normPrev : new Date(normPrev).getTime()
const curr = typeof normCurr === 'number' ? normCurr : new Date(normCurr as string).getTime()
if (isNaN(prev) || isNaN(curr)) return false
return curr - prev >= 5 * 60 * 1000
}