diff --git a/yunkes/src/api/index.ts b/yunkes/src/api/index.ts index 29dad02..8cbb2de 100644 --- a/yunkes/src/api/index.ts +++ b/yunkes/src/api/index.ts @@ -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> { 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 { return getJson( '/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 { + return getJson( + '/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, +): Promise { + return getJson( + '/staff-stats', + clean({ + wechat_id: query.wechat_id, + start: query.start, + end: query.end, + }), + ) +} + +export function getStaffStatsDetail( + query: StaffStatsQuery & { kind: string }, +): Promise { + return getJson( + '/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, diff --git a/yunkes/src/api/types.ts b/yunkes/src/api/types.ts index 7ab4cfb..e1d7c75 100644 --- a/yunkes/src/api/types.ts +++ b/yunkes/src/api/types.ts @@ -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[] +} diff --git a/yunkes/src/router/index.ts b/yunkes/src/router/index.ts index 2632131..f908d72 100644 --- a/yunkes/src/router/index.ts +++ b/yunkes/src/router/index.ts @@ -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(.*)*', diff --git a/yunkes/src/utils/__tests__/format.spec.ts b/yunkes/src/utils/__tests__/format.spec.ts new file mode 100644 index 0000000..5f8a87c --- /dev/null +++ b/yunkes/src/utils/__tests__/format.spec.ts @@ -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日`) + }) +}) diff --git a/yunkes/src/utils/format.ts b/yunkes/src/utils/format.ts index cfac857..703841a 100644 --- a/yunkes/src/utils/format.ts +++ b/yunkes/src/utils/format.ts @@ -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. diff --git a/yunkes/src/views/StaffChat.vue b/yunkes/src/views/StaffChat.vue new file mode 100644 index 0000000..91d8e08 --- /dev/null +++ b/yunkes/src/views/StaffChat.vue @@ -0,0 +1,14 @@ + + + + diff --git a/yunkes/tsconfig.app.json b/yunkes/tsconfig.app.json index 60ab997..53d1d95 100644 --- a/yunkes/tsconfig.app.json +++ b/yunkes/tsconfig.app.json @@ -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__/*"] }