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:
+75
-1
@@ -14,12 +14,15 @@ import type {
|
|||||||
Group,
|
Group,
|
||||||
GroupMember,
|
GroupMember,
|
||||||
Message,
|
Message,
|
||||||
|
MomentsList,
|
||||||
Page,
|
Page,
|
||||||
PhoneCall,
|
PhoneCall,
|
||||||
SessionList,
|
SessionList,
|
||||||
Sms,
|
Sms,
|
||||||
Staff,
|
Staff,
|
||||||
StaffChatStats,
|
StaffChatStats,
|
||||||
|
StaffStatsCounts,
|
||||||
|
StaffStatsDetail,
|
||||||
SyncRun,
|
SyncRun,
|
||||||
SyncState,
|
SyncState,
|
||||||
SyncStatus,
|
SyncStatus,
|
||||||
@@ -80,6 +83,10 @@ export interface MessageQuery {
|
|||||||
owner_account_id?: string
|
owner_account_id?: string
|
||||||
talker_id?: string
|
talker_id?: string
|
||||||
room_id?: string
|
room_id?: string
|
||||||
|
/** 结构化类型过滤(如 red_transfer),对齐云客聊天筛选。 */
|
||||||
|
msg_type?: string
|
||||||
|
/** 全文模糊搜索。 */
|
||||||
|
q?: string
|
||||||
start?: number
|
start?: number
|
||||||
end?: number
|
end?: number
|
||||||
page?: number
|
page?: number
|
||||||
@@ -94,6 +101,8 @@ export function getMessages(query: MessageQuery): Promise<Page<Message>> {
|
|||||||
owner_account_id: query.owner_account_id,
|
owner_account_id: query.owner_account_id,
|
||||||
talker_id: query.talker_id,
|
talker_id: query.talker_id,
|
||||||
room_id: query.room_id,
|
room_id: query.room_id,
|
||||||
|
msg_type: query.msg_type,
|
||||||
|
q: query.q,
|
||||||
start: query.start,
|
start: query.start,
|
||||||
end: query.end,
|
end: query.end,
|
||||||
page: query.page ?? 1,
|
page: query.page ?? 1,
|
||||||
@@ -327,12 +336,74 @@ export function getStaffChatStats(query: StaffChatStatsQuery): Promise<StaffChat
|
|||||||
export interface SessionQuery {
|
export interface SessionQuery {
|
||||||
owner_account_id: string
|
owner_account_id: string
|
||||||
channel?: Channel
|
channel?: Channel
|
||||||
|
/** 1 = 只看已删除会话(云客侧标记),缺省 = 全部。 */
|
||||||
|
is_del?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSessions(query: SessionQuery): Promise<SessionList> {
|
export function getSessions(query: SessionQuery): Promise<SessionList> {
|
||||||
return getJson<SessionList>(
|
return getJson<SessionList>(
|
||||||
'/sessions',
|
'/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,
|
Group,
|
||||||
GroupMember,
|
GroupMember,
|
||||||
Message,
|
Message,
|
||||||
|
MomentsList,
|
||||||
Page,
|
Page,
|
||||||
PhoneCall,
|
PhoneCall,
|
||||||
SessionList,
|
SessionList,
|
||||||
Sms,
|
Sms,
|
||||||
Staff,
|
Staff,
|
||||||
StaffChatStats,
|
StaffChatStats,
|
||||||
|
StaffStatsCounts,
|
||||||
|
StaffStatsDetail,
|
||||||
SyncRun,
|
SyncRun,
|
||||||
SyncState,
|
SyncState,
|
||||||
SyncStatus,
|
SyncStatus,
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export interface Account {
|
|||||||
channel: Channel
|
channel: Channel
|
||||||
account_id: string
|
account_id: string
|
||||||
nickname?: string | null
|
nickname?: string | null
|
||||||
|
avatar?: string | null
|
||||||
user_id?: string | null
|
user_id?: string | null
|
||||||
user_name?: string | null
|
user_name?: string | null
|
||||||
phone?: string | null
|
phone?: string | null
|
||||||
@@ -354,6 +355,8 @@ export interface ChatSession {
|
|||||||
avatar?: string | null
|
avatar?: string | null
|
||||||
today_count: number
|
today_count: number
|
||||||
last_chat_time?: number | null
|
last_chat_time?: number | null
|
||||||
|
/** 置顶会话(云客侧标记)。 */
|
||||||
|
pinned?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionList {
|
export interface SessionList {
|
||||||
@@ -362,3 +365,48 @@ export interface SessionList {
|
|||||||
today_start: number
|
today_start: number
|
||||||
sessions: ChatSession[]
|
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>[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -82,6 +82,15 @@ const routes: RouteRecordRaw[] = [
|
|||||||
props: true,
|
props: true,
|
||||||
meta: { title: '群聊记录' },
|
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.
|
// Anything else → back to the list.
|
||||||
path: '/:pathMatch(.*)*',
|
path: '/:pathMatch(.*)*',
|
||||||
|
|||||||
@@ -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日`)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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())}`
|
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).
|
* For voice messages the backend stores duration in seconds (best-effort).
|
||||||
* Render as `N"` or `M'SS"` to mirror the messaging-app look.
|
* Render as `N"` or `M'SS"` to mirror the messaging-app look.
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -14,5 +14,9 @@
|
|||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": 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__/*"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user