feat(admin-workplace): 工作台业务概览真实对接与全端认证集成 (更新远程代码仓配置与测试全绿)

这个提交包含在:
wikiboss
2026-09-21 09:22:14 +08:00
父节点 8c003b70cd
当前提交 e5c79082d3
共有 20 个文件被更改,包括 1452 次插入 和 143 次删除

查看文件

@@ -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);
}
}