feat(staffchat): 路由+API client+类型+relativeChatTime(还原旧页WIP+占位StaffChat)

- 还原 ChatTimeline.vue/api/index.ts 未提交 WIP(新页自建,旧文件回 HEAD)
- /staff-chat/:ownerId/:tab?/:talkerId? 路由 + 占位 StaffChat.vue(Task 9 替换)
- types: MomentsPost/MomentsList/StaffStatsCounts/StaffStatsDetail +
  ChatSession.pinned + Account.avatar
- api: getMoments/getStaffStatsCounts/getStaffStatsDetail + sessions
  is_del + messages msg_type/q 过滤
- format: relativeChatTime(今天HH:mm/昨天/M月D日/YYYY年M月D日) + 4 例
  vitest spec(全过,vitest 未入 devDependencies,npx 拉取运行)
- tsconfig.app.json exclude __tests__(vitest 未装,保持 vue-tsc 通过)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
浅念
2026-08-25 16:44:47 +08:00
parent 4baa8639ba
commit f2bcac0a4d
7 changed files with 208 additions and 2 deletions
+75 -1
View File
@@ -14,12 +14,15 @@ import type {
Group,
GroupMember,
Message,
MomentsList,
Page,
PhoneCall,
SessionList,
Sms,
Staff,
StaffChatStats,
StaffStatsCounts,
StaffStatsDetail,
SyncRun,
SyncState,
SyncStatus,
@@ -80,6 +83,10 @@ export interface MessageQuery {
owner_account_id?: string
talker_id?: string
room_id?: string
/** 结构化类型过滤(如 red_transfer),对齐云客聊天筛选。 */
msg_type?: string
/** 全文模糊搜索。 */
q?: string
start?: number
end?: number
page?: number
@@ -94,6 +101,8 @@ export function getMessages(query: MessageQuery): Promise<Page<Message>> {
owner_account_id: query.owner_account_id,
talker_id: query.talker_id,
room_id: query.room_id,
msg_type: query.msg_type,
q: query.q,
start: query.start,
end: query.end,
page: query.page ?? 1,
@@ -327,12 +336,74 @@ export function getStaffChatStats(query: StaffChatStatsQuery): Promise<StaffChat
export interface SessionQuery {
owner_account_id: string
channel?: Channel
/** 1 = 只看已删除会话(云客侧标记),缺省 = 全部。 */
is_del?: number
}
export function getSessions(query: SessionQuery): Promise<SessionList> {
return getJson<SessionList>(
'/sessions',
clean({ owner_account_id: query.owner_account_id, channel: query.channel }),
clean({
owner_account_id: query.owner_account_id,
channel: query.channel,
is_del: query.is_del,
}),
)
}
// ---------------------------------------------------------------------------
// 员工聊天窗口 (staff-chat, 对齐云客 wechat-window): 朋友圈 + 风控统计
// ---------------------------------------------------------------------------
export interface MomentsQuery {
wechat_id: string
page?: number
page_size?: number
}
export function getMoments(query: MomentsQuery): Promise<MomentsList> {
return getJson<MomentsList>(
'/moments',
clean({
wechat_id: query.wechat_id,
page: query.page,
page_size: query.page_size,
}),
)
}
export interface StaffStatsQuery {
wechat_id: string
/** 传 kind 走明细模式,缺省返回 7 数字 counts。 */
kind?: string
start?: number
end?: number
}
export function getStaffStatsCounts(
query: Omit<StaffStatsQuery, 'kind'>,
): Promise<StaffStatsCounts> {
return getJson<StaffStatsCounts>(
'/staff-stats',
clean({
wechat_id: query.wechat_id,
start: query.start,
end: query.end,
}),
)
}
export function getStaffStatsDetail(
query: StaffStatsQuery & { kind: string },
): Promise<StaffStatsDetail> {
return getJson<StaffStatsDetail>(
'/staff-stats',
clean({
wechat_id: query.wechat_id,
kind: query.kind,
start: query.start,
end: query.end,
}),
)
}
@@ -396,12 +467,15 @@ export type {
Group,
GroupMember,
Message,
MomentsList,
Page,
PhoneCall,
SessionList,
Sms,
Staff,
StaffChatStats,
StaffStatsCounts,
StaffStatsDetail,
SyncRun,
SyncState,
SyncStatus,
+48
View File
@@ -77,6 +77,7 @@ export interface Account {
channel: Channel
account_id: string
nickname?: string | null
avatar?: string | null
user_id?: string | null
user_name?: string | null
phone?: string | null
@@ -354,6 +355,8 @@ export interface ChatSession {
avatar?: string | null
today_count: number
last_chat_time?: number | null
/** 置顶会话(云客侧标记)。 */
pinned?: boolean
}
export interface SessionList {
@@ -362,3 +365,48 @@ export interface SessionList {
today_start: number
sessions: ChatSession[]
}
// ---------------------------------------------------------------------------
// 员工聊天窗口 (staff-chat, 对齐云客 wechat-window): 朋友圈 + 风控统计
// ---------------------------------------------------------------------------
/** 朋友圈单条动态(镜像 backend /api/moments 行)。 */
export interface MomentsPost {
id: string
wechat_id: string
content?: string | null
type_raw?: string | null
/** ms-epoch */
create_time?: number | null
author_name?: string | null
author_avatar?: string | null
/** 点赞者列表(仅取 name 展示)。 */
praise_json?: { name?: string }[] | null
/** 评论列表(结构随上游,前端按需解释)。 */
comment_json?: unknown[] | null
image_urls?: string[] | null
}
/** GET /api/moments 响应(header 为账号名片,total 为总数,items 分页)。 */
export interface MomentsList {
header: { nickname: string | null; avatar: string | null; alias: string | null }
total: number
items: MomentsPost[]
}
/** 员工风控统计 7 数字(GET /api/staff-stats 无 kind 时)。 */
export interface StaffStatsCounts {
new_customers: number
new_groups: number
red_transfer: number
sensitive_words: number
del_customers: number
blocked_customers: number
voice_calls: number
}
/** 员工风控统计 kind 明细(GET /api/staff-stats?kind=…,行结构随 kind 变化)。 */
export interface StaffStatsDetail {
kind: string
rows: Record<string, unknown>[]
}
+9
View File
@@ -82,6 +82,15 @@ const routes: RouteRecordRaw[] = [
props: true,
meta: { title: '群聊记录' },
},
{
// 员工聊天窗口(对齐云客 wechat-window 五视图): tab=single|group|moments|contacts|stats
// tab=group 时 talkerId 语义为 roomId; ?msgid= 定位消息(红包转账明细跳转)
path: '/staff-chat/:ownerId/:tab(single|group|moments|contacts|stats)?/:talkerId?',
name: 'staff-chat',
component: () => import('@/views/StaffChat.vue'),
props: true,
meta: { title: '员工聊天窗口' },
},
{
// Anything else → back to the list.
path: '/:pathMatch(.*)*',
+39
View File
@@ -0,0 +1,39 @@
// relativeChatTime 单测。注意: 项目尚未安装 vitest(devDependency 缺失),
// 本文件暂时不被 vue-tsc 检查(tsconfig.app.json exclude __tests__),装上
// vitest 后 `npx vitest run` 即可执行。
import { describe, expect, it } from 'vitest'
import { relativeChatTime } from '../format'
/** Local-time ms-epoch builder (h/min defaulted to noon so date math is stable). */
function at(y: number, m: number, d: number, h = 12, min = 0): number {
return new Date(y, m, d, h, min).getTime()
}
describe('relativeChatTime', () => {
it('今天 → HH:mm(含 00:01 日边界)', () => {
const now = new Date()
const y = now.getFullYear()
const m = now.getMonth()
const d = now.getDate()
expect(relativeChatTime(at(y, m, d, 0, 1))).toBe('00:01')
expect(relativeChatTime(at(y, m, d, 9, 5))).toBe('09:05')
})
it('昨天 → 昨天', () => {
const now = new Date()
// d-1 由 Date 自动归一化,月初/年初(跨月/跨年)也落在昨天
expect(relativeChatTime(at(now.getFullYear(), now.getMonth(), now.getDate() - 1, 8, 30))).toBe('昨天')
})
it('今年其它日期 → M月D日', () => {
const now = new Date()
// +6 个月保证与今天/昨天不同月,不会撞上今明两个分支
const m = (now.getMonth() + 6) % 12
expect(relativeChatTime(at(now.getFullYear(), m, 15))).toBe(`${m + 1}月15日`)
})
it('往年 → YYYY年M月D日', () => {
const lastYear = new Date().getFullYear() - 1
expect(relativeChatTime(at(lastYear, 5, 10))).toBe(`${lastYear}年6月10日`)
})
})
+18
View File
@@ -21,6 +21,24 @@ export function formatTimeShort(ts?: number | null): string {
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
/**
* 云客聊天窗口相对时间(会话列表/消息时间轴用): 今天→HH:mm,
* 昨天→`昨天`, 今年→`M月D日`, 往年→`YYYY年M月D日`。
*/
export function relativeChatTime(ms: number): string {
const d = new Date(ms)
const now = new Date()
const sameDay = (a: Date, b: Date) => a.toDateString() === b.toDateString()
const yest = new Date(now)
yest.setDate(now.getDate() - 1)
if (sameDay(d, now)) {
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
if (sameDay(d, yest)) return '昨天'
if (d.getFullYear() === now.getFullYear()) return `${d.getMonth() + 1}月${d.getDate()}日`
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`
}
/**
* For voice messages the backend stores duration in seconds (best-effort).
* Render as `N"` or `M'SS"` to mirror the messaging-app look.
+14
View File
@@ -0,0 +1,14 @@
<!-- 占位组件: Task 8 只加路由, 五视图实现在后续任务替换本文件。 -->
<script setup lang="ts">
// Route props (/staff-chat/:ownerId/:tab?/:talkerId?) — see router/index.ts.
// tab=group 时 talkerId 语义为 roomId; ?msgid= 定位消息(红包转账明细跳转)。
const props = defineProps<{
ownerId: string
tab?: string
talkerId?: string
}>()
</script>
<template>
<div data-staff-chat :data-owner-id="props.ownerId" />
</template>
+5 -1
View File
@@ -14,5 +14,9 @@
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
// vitest specs use `import from 'vitest'`, which isn't installed yet — keep
// them out of the app type-check (create-vue scaffold pattern; wire a
// tsconfig.vitest.json once vitest lands).
"exclude": ["src/**/__tests__/*"]
}