feat(admin-workplace): 工作台业务概览真实对接与全端认证集成 (更新远程代码仓配置与测试全绿)
这个提交包含在:
@@ -243,6 +243,48 @@ export interface RadarTrack {
|
||||
createdAt: string | Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 商务概览工作台 KPI
|
||||
*/
|
||||
export interface WorkplaceKpi {
|
||||
/** 今日 NFC 触碰量 */
|
||||
tapCount: number;
|
||||
/** 今日名片浏览量 */
|
||||
viewCount: number;
|
||||
/** 今日新线索 */
|
||||
newLeadCount: number;
|
||||
/** 成交转化率 (赢单 / 未归档线索, 0-100) */
|
||||
conversionRate: number;
|
||||
/** 触碰量较昨日差值 */
|
||||
tapDelta: number;
|
||||
/** 浏览量较昨日差值 */
|
||||
viewDelta: number;
|
||||
/** 新线索较昨日差值 */
|
||||
newLeadDelta: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作台雷达动态项
|
||||
*/
|
||||
export interface WorkplaceRadarItem {
|
||||
id: number | string;
|
||||
cardId: number | string;
|
||||
country: string;
|
||||
city: string;
|
||||
touchType: TouchType;
|
||||
stayDurationMs: number;
|
||||
viewedBlocks: string[];
|
||||
createTime: Date | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 商务概览工作台聚合结果
|
||||
*/
|
||||
export interface WorkplaceOverviewResult {
|
||||
kpis: WorkplaceKpi;
|
||||
radarFeed: WorkplaceRadarItem[];
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 3. NFC 实体卡设备台账契约 (NFC Fleet)
|
||||
// ==========================================
|
||||
|
||||
@@ -22,6 +22,7 @@ import { CrmRadarService } from '../../service/radar';
|
||||
{ method: 'getRecentTracks', summary: '最近足迹' },
|
||||
{ method: 'distribution', summary: '国家分布' },
|
||||
{ method: 'analytics', summary: '大屏综合指标' },
|
||||
{ method: 'getWorkplaceOverview', summary: '商务概览工作台' },
|
||||
],
|
||||
})
|
||||
export class AdminCrmRadarController extends BaseController {
|
||||
@@ -72,5 +73,25 @@ export class AdminCrmRadarController extends BaseController {
|
||||
});
|
||||
return this.ok(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 商务概览工作台
|
||||
* 路由: GET/POST /admin/crm/radar/workplace
|
||||
*/
|
||||
@Get('/workplace', { summary: '商务概览工作台' })
|
||||
@Post('/workplace', { summary: '商务概览工作台' })
|
||||
async workplace(
|
||||
@Body() body?: { feedLimit?: number },
|
||||
@Query('feedLimit') queryFeedLimit?: number
|
||||
) {
|
||||
const feedLimit = body?.feedLimit ?? queryFeedLimit;
|
||||
const data = await this.crmRadarService.getWorkplaceOverview({
|
||||
feedLimit:
|
||||
feedLimit !== undefined && feedLimit !== null && !isNaN(Number(feedLimit))
|
||||
? Number(feedLimit)
|
||||
: undefined,
|
||||
});
|
||||
return this.ok(data);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,137 @@ import { BaseService, CoolCommException } from '@cool-midway/core';
|
||||
import { InjectEntityModel } from '@midwayjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { CrmTrackEntity } from '../entity/track';
|
||||
import { TouchType } from '../../../comm/types/omni';
|
||||
import { CrmLeadEntity } from '../entity/lead';
|
||||
import {
|
||||
LeadStatus,
|
||||
TouchType,
|
||||
WorkplaceOverviewResult,
|
||||
WorkplaceRadarItem,
|
||||
} from '../../../comm/types/omni';
|
||||
import { lookupGeoIp } from '../../../comm/geoip/lookup';
|
||||
|
||||
interface WorkplaceTrackInput {
|
||||
id?: number | string;
|
||||
cardId?: number | string;
|
||||
country?: string;
|
||||
city?: string;
|
||||
touchType?: TouchType;
|
||||
stayDurationMs?: number;
|
||||
viewedBlocks?: string[];
|
||||
createTime?: Date | string;
|
||||
createdAt?: Date | string;
|
||||
}
|
||||
|
||||
interface WorkplaceLeadInput {
|
||||
status?: LeadStatus;
|
||||
createTime?: Date | string;
|
||||
createdAt?: Date | string;
|
||||
}
|
||||
|
||||
function toTimestamp(value?: Date | string): number {
|
||||
if (!value) {
|
||||
return 0;
|
||||
}
|
||||
const time = new Date(value).getTime();
|
||||
return Number.isFinite(time) ? time : 0;
|
||||
}
|
||||
|
||||
function startOfLocalDay(now: Date): Date {
|
||||
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
}
|
||||
|
||||
function resolveEventTime(item: { createTime?: Date | string; createdAt?: Date | string }): number {
|
||||
return toTimestamp(item.createTime ?? item.createdAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* 纯函数:按今日窗口聚合工作台 KPI 与雷达动态
|
||||
*/
|
||||
export function buildWorkplaceOverview(
|
||||
tracks: WorkplaceTrackInput[],
|
||||
leads: WorkplaceLeadInput[],
|
||||
now: Date,
|
||||
feedLimit = 12
|
||||
): WorkplaceOverviewResult {
|
||||
const todayStart = startOfLocalDay(now).getTime();
|
||||
const yesterdayStart = todayStart - 24 * 60 * 60 * 1000;
|
||||
const parsedLimit = Number(feedLimit);
|
||||
const take = Number.isFinite(parsedLimit)
|
||||
? Math.min(50, Math.max(1, Math.round(parsedLimit)))
|
||||
: 12;
|
||||
|
||||
let todayTaps = 0;
|
||||
let todayViews = 0;
|
||||
let yesterdayTaps = 0;
|
||||
let yesterdayViews = 0;
|
||||
|
||||
for (const item of tracks) {
|
||||
const time = resolveEventTime(item);
|
||||
const isNfc = item.touchType === 'nfc';
|
||||
if (time >= todayStart) {
|
||||
todayViews += 1;
|
||||
if (isNfc) {
|
||||
todayTaps += 1;
|
||||
}
|
||||
} else if (time >= yesterdayStart && time < todayStart) {
|
||||
yesterdayViews += 1;
|
||||
if (isNfc) {
|
||||
yesterdayTaps += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let todayLeads = 0;
|
||||
let yesterdayLeads = 0;
|
||||
let activeLeads = 0;
|
||||
let wonLeads = 0;
|
||||
|
||||
for (const item of leads) {
|
||||
const time = resolveEventTime(item);
|
||||
if (time >= todayStart) {
|
||||
todayLeads += 1;
|
||||
} else if (time >= yesterdayStart && time < todayStart) {
|
||||
yesterdayLeads += 1;
|
||||
}
|
||||
if (item.status !== 'archived') {
|
||||
activeLeads += 1;
|
||||
if (item.status === 'won') {
|
||||
wonLeads += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const radarFeed: WorkplaceRadarItem[] = [...tracks]
|
||||
.sort((a, b) => resolveEventTime(b) - resolveEventTime(a))
|
||||
.slice(0, take)
|
||||
.map((item) => ({
|
||||
id: item.id ?? 0,
|
||||
cardId: item.cardId ?? 0,
|
||||
country: item.country || 'Unknown',
|
||||
city: item.city || 'Unknown',
|
||||
touchType: item.touchType && ['nfc', 'qr', 'link'].includes(item.touchType)
|
||||
? item.touchType
|
||||
: 'link',
|
||||
stayDurationMs: Math.max(0, Math.round(item.stayDurationMs || 0)),
|
||||
viewedBlocks: Array.isArray(item.viewedBlocks) ? item.viewedBlocks.map(String) : [],
|
||||
createTime: item.createTime ?? item.createdAt ?? now,
|
||||
}));
|
||||
|
||||
return {
|
||||
kpis: {
|
||||
tapCount: todayTaps,
|
||||
viewCount: todayViews,
|
||||
newLeadCount: todayLeads,
|
||||
conversionRate:
|
||||
activeLeads > 0 ? Number(((wonLeads / activeLeads) * 100).toFixed(1)) : 0,
|
||||
tapDelta: todayTaps - yesterdayTaps,
|
||||
viewDelta: todayViews - yesterdayViews,
|
||||
newLeadDelta: todayLeads - yesterdayLeads,
|
||||
},
|
||||
radarFeed,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 访客足迹上报 DTO
|
||||
*/
|
||||
@@ -81,6 +209,9 @@ export class CrmRadarService extends BaseService {
|
||||
@InjectEntityModel(CrmTrackEntity)
|
||||
crmTrackEntity: Repository<CrmTrackEntity>;
|
||||
|
||||
@InjectEntityModel(CrmLeadEntity)
|
||||
crmLeadEntity: Repository<CrmLeadEntity>;
|
||||
|
||||
@Inject()
|
||||
ctx: any;
|
||||
|
||||
@@ -403,4 +534,21 @@ export class CrmRadarService extends BaseService {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 商务概览工作台:今日 KPI + 最近雷达动态
|
||||
*/
|
||||
async getWorkplaceOverview(params?: {
|
||||
feedLimit?: number;
|
||||
}): Promise<WorkplaceOverviewResult> {
|
||||
const feedLimit = params?.feedLimit;
|
||||
const [tracks, leads] = await Promise.all([
|
||||
this.crmTrackEntity.find({
|
||||
order: { id: 'DESC' },
|
||||
take: 500,
|
||||
}),
|
||||
this.crmLeadEntity.find(),
|
||||
]);
|
||||
return buildWorkplaceOverview(tracks, leads, new Date(), feedLimit);
|
||||
}
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户