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,
|
||||
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,
|
||||
|
||||
@@ -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>[]
|
||||
}
|
||||
|
||||
@@ -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(.*)*',
|
||||
|
||||
@@ -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())}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 云客聊天窗口相对时间(会话列表/消息时间轴用): 今天→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.
|
||||
|
||||
@@ -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,
|
||||
"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