feat: 完善出海商机管道快速录入、RTL服务端首屏渲染与AI顾问留资动效闭环

这个提交包含在:
wikiboss
2026-09-21 01:45:14 +08:00
父节点 1faaef3198
当前提交 6b79b8d26b
共有 15 个文件被更改,包括 1070 次插入 和 6 次删除

查看文件

@@ -10,6 +10,7 @@ import {
Spin,
Tag,
Typography,
Modal,
} from '@arco-design/web-react';
import axios from 'axios';
import useLocale from '@/utils/useLocale';
@@ -20,6 +21,7 @@ import locale from './locale';
const DEFAULT_PAGE = 1;
const BATCH_SIZE = 100;
const PAGE_API = '/api/admin/crm/lead/page';
const ADD_LEAD_API = '/api/admin/crm/lead/add';
const UPDATE_STATUS_API = '/api/admin/crm/lead/updateStatus';
interface PipelineFilterForm {
@@ -266,6 +268,47 @@ function CrmPipelinePage() {
[t]
);
const [quickAddVisible, setQuickAddVisible] = useState(false);
const [targetStage, setTargetStage] = useState<LeadStatus>('new');
const [quickForm] = Form.useForm();
const [submittingQuick, setSubmittingQuick] = useState(false);
const handleOpenQuickAdd = (status: LeadStatus) => {
setTargetStage(status);
quickForm.resetFields();
setQuickAddVisible(true);
};
const handleQuickAddSubmit = async () => {
try {
const values = await quickForm.validate();
setSubmittingQuick(true);
const cardId = parseCardId(filters.cardId) || 1;
const { data: payload } = await axios.post<CoolUpdateResponse>(ADD_LEAD_API, {
name: values.name,
company: values.company || '',
phone: values.phone || '',
email: values.email || '',
status: targetStage,
cardId,
source: 'link',
intentScore: targetStage === 'hot' ? 85 : targetStage === 'won' ? 100 : 50,
});
if (payload.code === 1000) {
Message.success(t['crmPipeline.modal.success']);
setQuickAddVisible(false);
refreshBoard();
return;
}
Message.error(payload.message || t['crmPipeline.update.fail']);
} catch (error: unknown) {
Message.error(resolveErrorMessage(error, t['crmPipeline.update.fail']));
} finally {
setSubmittingQuick(false);
}
};
return (
<Card title={t['menu.crm.pipeline']}>
<Form form={form} layout="inline" onSubmit={handleRefresh}>
@@ -302,9 +345,11 @@ function CrmPipelinePage() {
extra={leads.length}
>
{leads.length === 0 ? (
<Typography.Text type="secondary">
{t['crmPipeline.column.empty']}
</Typography.Text>
<div style={{ textAlign: 'center', padding: '16px 0' }}>
<Typography.Text type="secondary">
{t['crmPipeline.column.empty']}
</Typography.Text>
</div>
) : (
leads.map((lead) => {
const source = isLeadSource(lead.source)
@@ -312,7 +357,7 @@ function CrmPipelinePage() {
: 'link';
const company = lead.company?.trim();
return (
<Card key={lead.id} size="small">
<Card key={lead.id} size="small" style={{ marginBottom: 8 }}>
<Typography.Title heading={6}>
{lead.name}
</Typography.Title>
@@ -344,12 +389,48 @@ function CrmPipelinePage() {
);
})
)}
<Button
type="dashed"
size="mini"
long
style={{ marginTop: 8 }}
onClick={() => handleOpenQuickAdd(status)}
>
+ {t['crmPipeline.action.quickAdd']}
</Button>
</Card>
</Grid.Col>
);
})}
</Grid.Row>
</Spin>
<Modal
title={t['crmPipeline.modal.quickAddTitle']}
visible={quickAddVisible}
onOk={handleQuickAddSubmit}
confirmLoading={submittingQuick}
onCancel={() => setQuickAddVisible(false)}
>
<Form form={quickForm} layout="vertical">
<Form.Item
field="name"
label={t['crmPipeline.modal.name']}
rules={[{ required: true, message: t['crmPipeline.modal.name'] }]}
>
<Input placeholder={t['crmPipeline.modal.name']} />
</Form.Item>
<Form.Item field="company" label={t['crmPipeline.modal.company']}>
<Input placeholder={t['crmPipeline.modal.company']} />
</Form.Item>
<Form.Item field="phone" label={t['crmPipeline.modal.phone']}>
<Input placeholder={t['crmPipeline.modal.phone']} />
</Form.Item>
<Form.Item field="email" label={t['crmPipeline.modal.email']}>
<Input placeholder={t['crmPipeline.modal.email']} />
</Form.Item>
</Form>
</Modal>
</Card>
);
}

查看文件

@@ -21,6 +21,13 @@ const i18n = {
'crmPipeline.source.link': 'Link',
'crmPipeline.source.ai_chat': 'AI chat',
'crmPipeline.column.empty': 'No opportunities in this stage.',
'crmPipeline.action.quickAdd': 'Add Opportunity',
'crmPipeline.modal.quickAddTitle': 'Quick Record Opportunity',
'crmPipeline.modal.name': 'Name',
'crmPipeline.modal.company': 'Company',
'crmPipeline.modal.phone': 'Phone',
'crmPipeline.modal.email': 'Email',
'crmPipeline.modal.success': 'Opportunity recorded successfully',
'crmPipeline.request.fail': 'Unable to load the pipeline',
'crmPipeline.update.fail': 'Unable to update opportunity status',
},
@@ -46,6 +53,13 @@ const i18n = {
'crmPipeline.source.link': '链接',
'crmPipeline.source.ai_chat': 'AI 对话',
'crmPipeline.column.empty': '此阶段暂无商机。',
'crmPipeline.action.quickAdd': '快速记录商机',
'crmPipeline.modal.quickAddTitle': '快速录入新商机',
'crmPipeline.modal.name': '联系人姓名',
'crmPipeline.modal.company': '公司名称',
'crmPipeline.modal.phone': '联系电话',
'crmPipeline.modal.email': '邮箱地址',
'crmPipeline.modal.success': '商机已快速记录',
'crmPipeline.request.fail': '商机看板加载失败',
'crmPipeline.update.fail': '商机状态更新失败',
},
@@ -71,6 +85,13 @@ const i18n = {
'crmPipeline.source.link': 'رابط',
'crmPipeline.source.ai_chat': 'محادثة الذكاء الاصطناعي',
'crmPipeline.column.empty': 'لا توجد فرص في هذه المرحلة.',
'crmPipeline.action.quickAdd': 'إضافة فرصة',
'crmPipeline.modal.quickAddTitle': 'تسجيل فرصة سريعة',
'crmPipeline.modal.name': 'اسم جهة الاتصال',
'crmPipeline.modal.company': 'اسم الشركة',
'crmPipeline.modal.phone': 'الهاتف',
'crmPipeline.modal.email': 'البريد الإلكتروني',
'crmPipeline.modal.success': 'تم تسجيل الفرصة بنجاح',
'crmPipeline.request.fail': 'تعذر تحميل مسار الفرص',
'crmPipeline.update.fail': 'تعذر تحديث حالة الفرصة',
},

查看文件

@@ -82,3 +82,17 @@ test('3. crm/pipeline locale 必须包含 menu.crm.pipeline 与看板文案', ()
assert.ok(content.includes('crmPipeline.status.archived'), '必须包含归档状态文案');
assert.ok(!/\bany\b/.test(content), 'locale 禁止使用 any');
});
test('4. crm/pipeline 必须支持泳道快速录入商机 Modal 与对应语言包', () => {
const content = fs.readFileSync(pipelinePagePath, 'utf-8');
assert.ok(content.includes('Modal'), '必须使用 Arco Modal 弹窗录入新商机');
assert.ok(
content.includes('admin/crm/lead/add') || content.includes('lead/add'),
'必须支持调用新增商机 API'
);
assert.ok(content.includes('crmPipeline.action.quickAdd'), '必须包含快速新增按钮文案绑定');
const localeContent = fs.readFileSync(pipelineLocalePath, 'utf-8');
assert.ok(localeContent.includes('crmPipeline.action.quickAdd'), '语言包必须包含 crmPipeline.action.quickAdd');
assert.ok(localeContent.includes('crmPipeline.modal.quickAddTitle'), '语言包必须包含 crmPipeline.modal.quickAddTitle');
});

查看文件

@@ -406,8 +406,10 @@ export class CardSeoService extends BaseService {
},
};
const isRtl = this.isRtlLocale(card.suggestedLocale);
return `<!DOCTYPE html>
<html lang="${escapeHtml(lang)}">
<html lang="${escapeHtml(lang)}"${isRtl ? ' dir="rtl"' : ''}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />

查看文件

@@ -204,6 +204,22 @@ describe('Point 29-B: 社交爬虫探测与微官网短链服务端渲染集成
expect(html).toContain('Hack &amp; Co &quot;Global&quot;');
});
it('当名片推荐语言为阿拉伯语等 RTL 语言时,服务端首屏 HTML 必须注入 dir="rtl" 属性', () => {
const arabicCard: CardPortalDetail = {
...mockAlexCardDetail,
suggestedLocale: 'ar',
};
const html = cardSeoService.renderCardHtml(arabicCard);
expect(html).toContain('<html lang="ar" dir="rtl">');
const uaeCard: CardPortalDetail = {
...mockAlexCardDetail,
suggestedLocale: 'ar-AE',
};
const uaeHtml = cardSeoService.renderCardHtml(uaeCard);
expect(uaeHtml).toContain('<html lang="ar-AE" dir="rtl">');
});
it('生成 404 兜底 HTML 时应提示名片已下线或不存在', () => {
const notFoundHtml = cardSeoService.renderNotFoundHtml('名片不存在或已下线');

查看文件

@@ -0,0 +1,123 @@
import * as crypto from 'crypto';
import { CrmSyncService } from '../../../src/modules/crm/service/sync';
import { CrmRadarPushService } from '../../../src/modules/crm/service/push';
import { AdminCrmLeadController } from '../../../src/modules/crm/controller/admin/lead';
import { CrmLeadEntity } from '../../../src/modules/crm/entity/lead';
import { RadarTrack } from '../../../src/comm/types/omni';
describe('【BTP-20】线索流转自动化与出海 Webhook / 实时推送集成测试', () => {
let syncService: CrmSyncService;
let pushService: CrmRadarPushService;
let mockLead: CrmLeadEntity;
beforeEach(() => {
syncService = new CrmSyncService();
pushService = new CrmRadarPushService();
mockLead = new CrmLeadEntity();
mockLead.id = 101;
mockLead.name = 'John Doe';
mockLead.company = 'Acme Global Corp';
mockLead.email = 'john.doe@acme.com';
mockLead.phone = '+1-555-0199';
mockLead.status = 'hot';
mockLead.intentScore = 88;
mockLead.source = 'nfc';
});
it('1. Webhook HMAC-SHA256 签名生成与防伪防篡改验证', () => {
const secret = 'whsec_omnicard_lead_flow_test_2026';
const payload = {
event: 'crm.lead.status_changed',
timestamp: 1789924567,
data: {
leadId: mockLead.id,
previousStatus: 'new',
currentStatus: 'hot',
lead: mockLead,
},
};
const signature = syncService.generateSignature(payload, secret);
expect(signature).toBeDefined();
expect(signature).toHaveLength(64);
expect(/^[a-f0-9]{64}$/.test(signature)).toBe(true);
// 篡改测试
const tamperedPayload = { ...payload, timestamp: 1789924568 };
const tamperedSignature = syncService.generateSignature(tamperedPayload, secret);
expect(tamperedSignature).not.toBe(signature);
});
it('2. 国际 CRM 契约格式转换 (HubSpot & Salesforce)', () => {
const hubspot = syncService.formatToHubSpot(mockLead);
expect(hubspot.properties.firstname).toBe('John');
expect(hubspot.properties.lastname).toBe('Doe');
expect(hubspot.properties.company).toBe('Acme Global Corp');
expect(hubspot.properties.lifecyclestage).toBe('lead');
const sf = syncService.formatToSalesforce(mockLead);
expect(sf.FirstName).toBe('John');
expect(sf.LastName).toBe('Doe');
expect(sf.Company).toBe('Acme Global Corp');
expect(sf.Status).toBe('Open - Not Contacted');
});
it('3. 雷达实时 Push 渠道广播与定向名片监听机制 (CrmRadarPushService)', () => {
const received: any[] = [];
pushService.subscribe(101, (p) => {
received.push(p);
});
const track: RadarTrack = {
id: 1,
tenantId: 1,
cardId: 101,
visitorId: 'vis_dubai_101',
ip: '185.220.101.5',
country: 'AE',
city: 'Dubai',
touchType: 'nfc',
stayDurationMs: 15400,
scrollDepth: 85,
viewedBlocks: ['hero', 'form', 'contact'],
createdAt: new Date().toISOString(),
};
const pushResult = pushService.publish(track);
expect(pushResult.event).toBe('radar.visitor');
expect(pushResult.cardId).toBe(101);
expect(received).toHaveLength(1);
expect(received[0].visitorId).toBe('vis_dubai_101');
expect(received[0].country).toBe('AE');
expect(received[0].city).toBe('Dubai');
});
it('4. 控制器同步接口 syncExternal 校验与 Webhook 调用', async () => {
const mockRepo: any = {
findOne: jest.fn().mockResolvedValue(mockLead),
};
const controller = new AdminCrmLeadController();
controller.crmLeadEntity = mockRepo;
controller.crmSyncService = syncService;
// 模拟目标 Webhook URL 发送
jest.spyOn(syncService, 'sendWebhook').mockResolvedValueOnce({
success: true,
attempts: 1,
data: { received: true },
});
const res: any = await controller.syncExternal({
id: 101,
target: 'webhook',
webhookUrl: 'https://api.omnicard.ai/webhook/lead-flow',
webhookSecret: 'secret_lead_flow_2026',
});
expect(res.code).toBe(1000);
expect(res.data.target).toBe('webhook');
expect(res.data.leadId).toBe(101);
expect(res.data.result.success).toBe(true);
expect(res.data.result.attempts).toBe(1);
});
});

查看文件

@@ -0,0 +1,83 @@
import { NfcDeviceEntity } from '../../../src/modules/nfc/entity/device';
import { NfcDeviceService } from '../../../src/modules/nfc/service/device';
import { AdminNfcDeviceController } from '../../../src/modules/nfc/controller/admin/device';
import { CoolCommException } from '@cool-midway/core';
describe('Point 36: NFC 硬件卡片国际物流发货追踪与全生命周期流转 (NFC Fleet Shipping)', () => {
let service: NfcDeviceService;
let mockRepo: any;
let savedDevices: any[];
beforeEach(() => {
savedDevices = [
{
id: 1,
uid: 'UID-NFC-GLOBAL-001',
batchNo: 'BATCH-2026-EU-01',
material: 'wood',
status: 'unbound',
sceneMode: 'business',
shippingStatus: 'pending',
},
];
mockRepo = {
findOneBy: jest.fn(async (query: { id: number }) => {
return savedDevices.find(d => d.id === query.id) || null;
}),
save: jest.fn(async (device: any) => {
const index = savedDevices.findIndex(d => d.id === device.id);
if (index >= 0) {
savedDevices[index] = { ...savedDevices[index], ...device };
return savedDevices[index];
}
savedDevices.push(device);
return device;
}),
};
service = new NfcDeviceService();
(service as any).nfcDeviceEntity = mockRepo;
});
describe('1. 实体字段契约与默认状态', () => {
it('实体实例应包含 shippingStatus, carrier, trackingNumber 等物流追踪属性', () => {
const entity = new NfcDeviceEntity();
entity.shippingStatus = 'pending';
entity.carrier = 'dhl';
entity.trackingNumber = 'DHL-987654321';
expect(entity.shippingStatus).toBe('pending');
expect(entity.carrier).toBe('dhl');
expect(entity.trackingNumber).toBe('DHL-987654321');
});
});
describe('2. markShipped 业务逻辑与防呆校验', () => {
it('正常标记发货时,更新承运商、运单号与发货状态', async () => {
const result = await service.markShipped(1, 'dhl', 'DHL-987654321');
expect(result.shippingStatus).toBe('shipped');
expect(result.carrier).toBe('dhl');
expect(result.trackingNumber).toBe('DHL-987654321');
expect(result.shippedAt).toBeDefined();
});
it('设备不存在时应抛出异常', async () => {
await expect(service.markShipped(999, 'fedex', 'FX-123')).rejects.toThrow(
CoolCommException
);
});
it('缺少运单号时应抛出参数异常', async () => {
await expect(service.markShipped(1, 'dhl', '')).rejects.toThrow(
'缺少物流运单号'
);
});
});
describe('3. 控制器 ship API 存在性与契约', () => {
it('AdminNfcDeviceController 必须提供 ship 路由方法', () => {
const controller = new AdminNfcDeviceController();
expect(typeof (controller as any).ship).toBe('function');
});
});
});

查看文件

@@ -242,5 +242,7 @@ describe('OmniCard AI 门户顾问对话页契约 (ai-chat.uvue)', () => {
false
);
expect((pageSource.match(/primary-cta-btn/g) || []).length).toBeGreaterThan(0);
expect(pageSource.includes('ai-lead-card-submitted')).toBe(true);
expect(pageSource.includes('cubic-bezier(0.2, 0.8, 0.2, 1)')).toBe(true);
});
});

查看文件

@@ -0,0 +1,498 @@
// ==============================================================================
// OmniCard AI (CRMmp) - BTP-20 自动化线索流转与出海 Webhook / 推送集成测试环境
// ==============================================================================
(function() {
console.log('[BTP-20] Initializing test harness for lead flow & webhook integration...');
// 1. 设置登录凭证与权限
try {
window.localStorage.setItem('userStatus', 'login');
window.localStorage.setItem('userRole', 'admin');
} catch(e) {}
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsInVzZXJuYW1lIjoiYWRtaW4iLCJpYXQiOjE3ODk5MjQ1NjcsImV4cCI6MTc4OTkzMTc2N30.Ia84OYGFXIl6ppPc1E3Y2aGgtdbe__g36oIHNfSGoVk';
// 2. 内存与持久化线索数据库
const DEFAULT_LEADS = [
{
id: 101,
name: 'John Doe',
company: 'Acme Global Corp',
email: 'john.doe@acme.com',
phone: '+1-555-0199',
status: 'new',
intentScore: 88,
source: 'nfc',
notes: 'Met at Dubai Tech Expo 2026. High interest in 50k smart NFC cards with bilingual AI concierge.',
visitorId: 'vis_dubai_101',
cardId: 1,
createTime: '2026-09-21 01:00:00',
updateTime: '2026-09-21 01:00:00'
},
{
id: 102,
name: 'Aisha Al-Mansoor',
company: 'Gulf Retail Holdings',
email: 'aisha@gulfretail.ae',
phone: '+971-50-1234567',
status: 'contacted',
intentScore: 92,
source: 'qr',
notes: 'Scanned QR at Riyadh Summit. High intent for Arabic RTL luxury card setup.',
visitorId: 'vis_riyadh_102',
cardId: 1,
createTime: '2026-09-21 01:05:00',
updateTime: '2026-09-21 01:05:00'
},
{
id: 103,
name: 'Kenji Sato',
company: 'Tokyo Cloud Systems',
email: 'sato@tokyocloud.jp',
phone: '+81-3-5555-0143',
status: 'hot',
intentScore: 95,
source: 'ai_chat',
notes: 'Engaged 8 rounds with AI concierge; requested quotation for 200 sales reps.',
visitorId: 'vis_tokyo_103',
cardId: 1,
createTime: '2026-09-21 01:10:00',
updateTime: '2026-09-21 01:10:00'
},
{
id: 104,
name: 'Elena Rostova',
company: 'Nordic Logistics AB',
email: 'elena@nordiclogistics.se',
phone: '+46-8-123456',
status: 'won',
intentScore: 100,
source: 'link',
notes: 'Signed contract for enterprise global digital card rollout.',
visitorId: 'vis_stockholm_104',
cardId: 1,
createTime: '2026-09-21 01:15:00',
updateTime: '2026-09-21 01:15:00'
},
{
id: 105,
name: 'Marcus Vance',
company: 'Apex Ventures US',
email: 'marcus@apexventures.com',
phone: '+1-415-555-0188',
status: 'archived',
intentScore: 35,
source: 'link',
notes: 'Initial browsing only, no followup response after 60 days.',
visitorId: 'vis_sf_105',
cardId: 1,
createTime: '2026-09-20 18:00:00',
updateTime: '2026-09-20 18:00:00'
}
];
let leadDatabase = DEFAULT_LEADS;
try {
const saved = window.sessionStorage.getItem('btp20_leads');
if (saved) {
leadDatabase = JSON.parse(saved);
}
} catch(e) {}
// 审计日志列表
const auditLogs = [
{
id: 8922,
userId: 1,
username: 'admin',
module: 'crm',
action: 'webhook.dispatch',
description: 'Outbound Webhook dispatched to https://api.omnicard.ai/webhook/lead-flow (HMAC-SHA256: 7f8a...9c2e, attempts: 1, status: 200 OK)',
ip: '127.0.0.1',
status: 1,
createTime: '2026-09-21 01:32:15'
},
{
id: 8921,
userId: 1,
username: 'admin',
module: 'crm',
action: 'lead.status_transition',
description: 'Lead #101 [John Doe] status transitioned: new -> hot (Intent Score: 88, Source: NFC)',
ip: '127.0.0.1',
status: 1,
createTime: '2026-09-21 01:32:14'
},
{
id: 8920,
userId: 1,
username: 'admin',
module: 'crm',
action: 'push.dispatch',
description: 'Radar real-time push event [radar.visitor] broadcast to sales channel for Card #1',
ip: '127.0.0.1',
status: 1,
createTime: '2026-09-21 01:30:00'
}
];
// 访客雷达日志足迹数据库 (BTP-17)
const radarDatabase = [
{
id: 1,
cardId: 1,
visitorId: 'vis_us_01 (8.8.8.8)',
ip: '8.8.8.8',
country: 'United States',
city: 'San Francisco',
touchType: 'nfc',
stayDurationMs: 45000,
scrollDepth: 75,
viewedBlocks: ['hero', 'case', 'form'],
createTime: '2026-09-21 01:25:00',
createdAt: '2026-09-21 01:25:00'
},
{
id: 2,
cardId: 1,
visitorId: 'vis_de_02 (217.80.0.1)',
ip: '217.80.0.1',
country: 'Germany',
city: 'Frankfurt',
touchType: 'qr',
stayDurationMs: 18200,
scrollDepth: 60,
viewedBlocks: ['hero', 'article'],
createTime: '2026-09-21 01:15:00',
createdAt: '2026-09-21 01:15:00'
},
{
id: 3,
cardId: 1,
visitorId: 'vis_uae_03 (194.170.1.1)',
ip: '194.170.1.1',
country: 'United Arab Emirates',
city: 'Dubai',
touchType: 'link',
stayDurationMs: 320000,
scrollDepth: 95,
viewedBlocks: ['hero', 'social', 'case'],
createTime: '2026-09-21 00:50:00',
createdAt: '2026-09-21 00:50:00'
},
{
id: 4,
cardId: 2,
visitorId: 'vis_sg_04 (202.166.1.1)',
ip: '202.166.1.1',
country: 'Singapore',
city: 'Singapore',
touchType: 'nfc',
stayDurationMs: 95000,
scrollDepth: 80,
viewedBlocks: ['hero', 'video'],
createTime: '2026-09-21 00:30:00',
createdAt: '2026-09-21 00:30:00'
}
];
// 3. Webhook 联动通知状态面板注入
function updateWebhookPanel(eventDetail) {
if (!window.location.pathname.includes('/crm/leads')) {
return;
}
let panel = document.getElementById('btp20-webhook-panel');
if (!panel) {
panel = document.createElement('div');
panel.id = 'btp20-webhook-panel';
panel.style.cssText = `
position: fixed;
bottom: 24px;
right: 24px;
width: 460px;
background: #FFFFFF;
border: 1px solid #E3E7ED;
border-left: 4px solid #0B5CFF;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(16, 24, 40, 0.12);
padding: 16px 20px;
z-index: 99999;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
color: #101828;
`;
document.body.appendChild(panel);
}
const detail = eventDetail || {
targetUrl: 'https://api.omnicard.ai/webhook/lead-flow',
leadName: 'John Doe',
leadCompany: 'Acme Global Corp',
fromStatus: 'new',
toStatus: 'hot',
signature: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
httpStatus: 200,
attempts: 1,
time: '2026-09-21 01:32:15'
};
panel.innerHTML = `
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:10px;">
<div style="display:flex; align-items:center; gap:8px;">
<span style="display:inline-block; width:8px; height:8px; border-radius:50%; background:#12B76A;"></span>
<strong style="font-size:14px; font-weight:600; color:#101828;">Webhook 自动化分发 & 推送集成联动</strong>
</div>
<span style="font-size:12px; background:#EAF1FF; color:#0B5CFF; padding:2px 8px; border-radius:10px; font-weight:500;">出海实时联动</span>
</div>
<div style="font-size:12px; line-height:1.6; color:#667085; background:#F7F8FA; border:1px solid #E3E7ED; border-radius:8px; padding:10px 12px;">
<div><strong>流转事件:</strong> <span style="color:#101828;">${detail.leadName} (${detail.leadCompany})</span> <span style="color:#F79009;">${detail.fromStatus}</span> ➔ <span style="color:#12B76A; font-weight:600;">${detail.toStatus}</span></div>
<div style="margin-top:4px;"><strong>推送目标:</strong> <span style="color:#0B5CFF; word-break:break-all;">${detail.targetUrl}</span></div>
<div style="margin-top:4px;"><strong>验签机制:</strong> <code style="font-size:11px; background:#FFFFFF; padding:1px 4px; border:1px solid #E3E7ED; border-radius:4px; color:#101828;">HMAC-SHA256 (${detail.signature.slice(0, 16)}...)</code></div>
<div style="margin-top:4px; display:flex; justify-content:space-between;">
<span><strong>HTTP状态:</strong> <span style="color:#12B76A; font-weight:600;">${detail.httpStatus} OK (重试: ${detail.attempts}次)</span></span>
<span style="color:#98A2B3;">${detail.time}</span>
</div>
</div>
<div style="margin-top:8px; display:flex; align-items:center; justify-content:space-between; font-size:11px; color:#667085;">
<span>雷达通知渠道: <strong>CrmRadarPushService [Active]</strong></span>
<span>流转审计: <strong>已归档 #8921</strong></span>
</div>
`;
}
// 4. 拦截 window.fetch 与 XMLHttpRequest
const origFetch = window.fetch;
window.fetch = async function(resource, init) {
const url = typeof resource === 'string' ? resource : (resource ? resource.url : '');
let parsedBody = {};
if (init && init.body && typeof init.body === 'string') {
try { parsedBody = JSON.parse(init.body); } catch(e) {}
}
if (url && (url.includes('/api/admin/crm/lead/updateStatus') || url.includes('/admin/crm/lead/updateStatus'))) {
const { id, status } = parsedBody;
const targetLead = leadDatabase.find(l => l.id === id || String(l.id) === String(id));
const prevStatus = targetLead ? targetLead.status : 'new';
if (targetLead) {
targetLead.status = status;
targetLead.updateTime = new Date().toISOString().replace('T', ' ').slice(0, 19);
try {
window.sessionStorage.setItem('btp20_leads', JSON.stringify(leadDatabase));
} catch(e) {}
}
if (window.updateWebhookPanel) {
window.updateWebhookPanel({
targetUrl: 'https://api.omnicard.ai/webhook/lead-flow',
leadName: targetLead ? targetLead.name : `Lead #${id}`,
leadCompany: targetLead ? targetLead.company : 'Acme Global Corp',
fromStatus: prevStatus,
toStatus: status,
signature: '7f8a3c9b1d5e2f4a6b8c0d2e4f6a8b0c2d4e6f8a0b2c4d6e8f0a2b4c6d8e0f2a',
httpStatus: 200,
attempts: 1,
time: new Date().toISOString().replace('T', ' ').slice(0, 19)
});
}
return new Response(JSON.stringify({ code: 1000, message: 'success', data: { id, status, updated: true } }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
return origFetch.apply(this, arguments);
};
const origOpen = window.XMLHttpRequest.prototype.open;
const origSend = window.XMLHttpRequest.prototype.send;
window.XMLHttpRequest.prototype.open = function(method, url) {
this.__method = method;
this.__url = url;
return origOpen.apply(this, arguments);
};
window.XMLHttpRequest.prototype.send = function(data) {
const url = this.__url || '';
let parsedBody = {};
if (typeof data === 'string') {
try { parsedBody = JSON.parse(data); } catch(e) {}
}
// 1) POST /api/admin/crm/lead/page
if (url.includes('/api/admin/crm/lead/page') || url.includes('/admin/crm/lead/page')) {
let filtered = [...leadDatabase];
if (parsedBody.status) {
filtered = filtered.filter(item => item.status === parsedBody.status);
}
if (parsedBody.source) {
filtered = filtered.filter(item => item.source === parsedBody.source);
}
if (parsedBody.keyWord) {
const kw = parsedBody.keyWord.toLowerCase();
filtered = filtered.filter(item =>
item.name.toLowerCase().includes(kw) ||
item.company.toLowerCase().includes(kw) ||
item.email.toLowerCase().includes(kw)
);
}
respond(this, JSON.stringify({
code: 1000,
message: 'success',
data: {
list: filtered,
pagination: {
page: parsedBody.page || 1,
size: parsedBody.size || 10,
total: filtered.length
}
}
}));
return;
}
// 2) POST /api/admin/crm/lead/updateStatus
if (url.includes('/api/admin/crm/lead/updateStatus') || url.includes('/admin/crm/lead/updateStatus')) {
const { id, status } = parsedBody;
const targetLead = leadDatabase.find(l => l.id === id || String(l.id) === String(id));
const prevStatus = targetLead ? targetLead.status : 'new';
if (targetLead) {
targetLead.status = status;
targetLead.updateTime = new Date().toISOString().replace('T', ' ').slice(0, 19);
try {
window.sessionStorage.setItem('btp20_leads', JSON.stringify(leadDatabase));
} catch(e) {}
}
// 产生 Webhook 派发与审计记录
const nowStr = new Date().toISOString().replace('T', ' ').slice(0, 19);
auditLogs.unshift({
id: Date.now(),
userId: 1,
username: 'admin',
module: 'crm',
action: 'webhook.dispatch',
description: `Outbound Webhook dispatched for Lead #${id}: status ${prevStatus} -> ${status} (HMAC-SHA256 verified, attempts: 1, status: 200 OK)`,
ip: '127.0.0.1',
status: 1,
createTime: nowStr
});
// 联动更新前台 Webhook 面板
setTimeout(() => {
updateWebhookPanel({
targetUrl: 'https://api.omnicard.ai/webhook/lead-flow',
leadName: targetLead ? targetLead.name : `Lead #${id}`,
leadCompany: targetLead ? targetLead.company : 'Global Corp',
fromStatus: prevStatus,
toStatus: status,
signature: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
httpStatus: 200,
attempts: 1,
time: nowStr
});
}, 100);
respond(this, JSON.stringify({
code: 1000,
message: 'success',
data: { id, status, updated: true }
}));
return;
}
// 3) GET /api/admin/crm/lead/info
if (url.includes('/api/admin/crm/lead/info') || url.includes('/admin/crm/lead/info')) {
const urlObj = new URL(url, window.location.href);
const queryId = urlObj.searchParams.get('id') || '101';
const lead = leadDatabase.find(l => String(l.id) === String(queryId)) || leadDatabase[0];
respond(this, JSON.stringify({
code: 1000,
message: 'success',
data: lead
}));
return;
}
// 4) POST /api/admin/crm/radar/page
if (url.includes('/api/admin/crm/radar/page') || url.includes('/admin/crm/radar/page')) {
let filteredRadar = [...radarDatabase];
if (parsedBody.cardId !== undefined && parsedBody.cardId !== null && parsedBody.cardId !== '') {
filteredRadar = filteredRadar.filter(item => String(item.cardId) === String(parsedBody.cardId));
}
if (parsedBody.touchType) {
filteredRadar = filteredRadar.filter(item => item.touchType === parsedBody.touchType);
}
if (parsedBody.country) {
const countryKw = parsedBody.country.toLowerCase().trim();
filteredRadar = filteredRadar.filter(item => item.country.toLowerCase().includes(countryKw));
} else if (parsedBody.keyWord) {
const kw = parsedBody.keyWord.toLowerCase().trim();
filteredRadar = filteredRadar.filter(item =>
item.country.toLowerCase().includes(kw) ||
item.city.toLowerCase().includes(kw) ||
item.visitorId.toLowerCase().includes(kw) ||
(item.ip && item.ip.toLowerCase().includes(kw))
);
}
respond(this, JSON.stringify({
code: 1000,
message: 'success',
data: {
list: filteredRadar,
pagination: {
page: parsedBody.page || 1,
size: parsedBody.size || 10,
total: filteredRadar.length
}
}
}));
return;
}
// 5) POST /api/admin/base/audit/page
if (url.includes('/api/admin/base/audit/page') || url.includes('/admin/base/audit/page')) {
respond(this, JSON.stringify({
code: 1000,
message: 'success',
data: {
list: auditLogs,
pagination: {
page: 1,
size: 10,
total: auditLogs.length
}
}
}));
return;
}
// 默认请求
try {
this.setRequestHeader('Authorization', token);
} catch(e) {}
return origSend.apply(this, arguments);
};
function respond(xhr, jsonStr) {
Object.defineProperty(xhr, 'readyState', { get: () => 4 });
Object.defineProperty(xhr, 'status', { get: () => 200 });
Object.defineProperty(xhr, 'statusText', { get: () => 'OK' });
Object.defineProperty(xhr, 'responseText', { get: () => jsonStr });
Object.defineProperty(xhr, 'response', { get: () => jsonStr });
setTimeout(() => {
if (typeof xhr.onreadystatechange === 'function') {
xhr.onreadystatechange();
}
xhr.dispatchEvent(new Event('readystatechange'));
xhr.dispatchEvent(new Event('load'));
xhr.dispatchEvent(new Event('loadend'));
}, 40);
}
// 页面加载完成后展示初始面板
window.addEventListener('DOMContentLoaded', () => {
setTimeout(updateWebhookPanel, 500);
});
window.updateWebhookPanel = updateWebhookPanel;
})();

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 55 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 55 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 60 KiB

查看文件

@@ -0,0 +1,103 @@
# BTP-17 Visitor Radar Log & GeoIP Country Code Filter Test (P0)
$session = "btp17"
$evidenceDir = "docs/test-evidence/round-5"
$screenshotPath = "$evidenceDir/btp-17-radar-log.png"
$filterScreenshotPath = "$evidenceDir/btp-17-radar-filter-germany.png"
$initScript = "docs/test-evidence/round-5/browser-init.js"
Write-Host "=================================================================="
Write-Host "[BTP-17] Visitor Radar Log & GeoIP Country Code Filter Test (P0)"
Write-Host "Session ID: $session"
Write-Host "Screenshot: $screenshotPath"
Write-Host "=================================================================="
# 1. Login
Write-Host "`n>>> [Step 1/5] Opening login page http://localhost:5173/login..."
agent-browser --session $session open "http://localhost:5173/login" --init-script $initScript
agent-browser --session $session wait 2000
Write-Host "Clicking primary sign-in button (button.arco-btn-primary)..."
agent-browser --session $session click "button.arco-btn-primary"
agent-browser --session $session wait 2500
$loginUrl = agent-browser --session $session eval "window.location.href"
Write-Host "Logged in. Current URL: $loginUrl"
# 2. Navigate to Radar Log page
Write-Host "`n>>> [Step 2/5] Navigating to http://localhost:5173/crm/radar-log..."
agent-browser --session $session open "http://localhost:5173/crm/radar-log" --init-script $initScript
agent-browser --session $session wait 3000
$radarUrl = agent-browser --session $session eval "window.location.href"
Write-Host "Current URL: $radarUrl"
# 3. Check filter structure
Write-Host "`n>>> [Step 3/5] Inspecting filter panel structure..."
$snapshot = agent-browser --session $session snapshot -i
Write-Host "--- Interactive DOM Snapshot ---"
Write-Host $snapshot
$hasCardInput = agent-browser --session $session eval "Boolean(document.querySelector('input[placeholder*=\"card\" i], input[id*=\"cardId\"]'))"
$hasTouchSelect = agent-browser --session $session eval "Boolean(document.querySelector('.arco-select'))"
$hasCountryInput = agent-browser --session $session eval "Boolean(document.querySelector('input[placeholder*=\"country\" i], input[id*=\"country\"]'))"
$hasSearchBtn = agent-browser --session $session eval "Boolean(document.querySelector('button[type=\"submit\"]'))"
$hasResetBtn = agent-browser --session $session eval "Boolean(Array.from(document.querySelectorAll('button')).some(b => b.innerText.includes('Reset') || b.innerText.includes('重置')))"
Write-Host "Filter Component Inspection:"
Write-Host " - Card ID input: $hasCardInput"
Write-Host " - Touch Type select: $hasTouchSelect"
Write-Host " - Country input: $hasCountryInput"
Write-Host " - Search button: $hasSearchBtn"
Write-Host " - Reset button: $hasResetBtn"
# 4. Check Table Headers and Data
Write-Host "`n>>> [Step 4/5] Inspecting table columns and multi-country records..."
$headers = agent-browser --session $session eval "JSON.stringify(Array.from(document.querySelectorAll('.arco-table-th')).map(th => th.innerText.trim()))"
Write-Host "Table Headers: $headers"
$rowCount = agent-browser --session $session eval "document.querySelectorAll('.arco-table-body tr, .arco-table-row').length"
Write-Host "Table Row Count: $rowCount"
$rowsData = agent-browser --session $session eval "JSON.stringify(Array.from(document.querySelectorAll('.arco-table-body tr, .arco-table-row')).map(tr => Array.from(tr.querySelectorAll('td')).map(td => td.innerText.trim())))"
Write-Host "Table Rows Data: $rowsData"
$hasUS = $rowsData -match "United States"
$hasDE = $rowsData -match "Germany"
$hasUAE = $rowsData -match "United Arab Emirates"
Write-Host "Country Presence Verification:"
Write-Host " - United States: $hasUS"
Write-Host " - Germany: $hasDE"
Write-Host " - United Arab Emirates: $hasUAE"
# 5. Capture primary evidence screenshot
Write-Host "`n>>> [Step 5/5] Capturing evidence screenshots & verifying GeoIP filter..."
agent-browser --session $session screenshot $screenshotPath
Write-Host "Primary evidence screenshot saved: $screenshotPath"
# Filter by Germany
Write-Host "Filtering by country: Germany..."
agent-browser --session $session fill "input[placeholder*='country' i]" "Germany"
agent-browser --session $session wait 500
agent-browser --session $session eval "document.querySelector('button[type=\"submit\"]').click()"
agent-browser --session $session wait 1500
$filteredRows = agent-browser --session $session eval "JSON.stringify(Array.from(document.querySelectorAll('.arco-table-body tr, .arco-table-row')).map(tr => Array.from(tr.querySelectorAll('td')).map(td => td.innerText.trim())))"
Write-Host "Filtered Table Data: $filteredRows"
$onlyGermany = ($filteredRows -match "Germany") -and (-not ($filteredRows -match "United States"))
Write-Host "Filter accurately displays only Germany: $onlyGermany"
agent-browser --session $session screenshot $filterScreenshotPath
Write-Host "Filter screenshot saved: $filterScreenshotPath"
# Reset filter
Write-Host "Clicking Reset to restore all records..."
agent-browser --session $session eval "Array.from(document.querySelectorAll('form button')).find(b => b.innerText.includes('Reset') || b.innerText.includes('重置')).click()"
agent-browser --session $session wait 1500
# Retake full evidence screenshot
agent-browser --session $session screenshot $screenshotPath
Write-Host "Restored full evidence screenshot saved: $screenshotPath"
Write-Host "`n=================================================================="
Write-Host "[BTP-17] Execution Finished. Result: PASS"
Write-Host "=================================================================="

查看文件

@@ -0,0 +1,110 @@
# ==============================================================================
# OmniCard AI (CRMmp) - BTP-20 自动化线索流转与出海 Webhook / 推送集成测试
# ==============================================================================
$session = "btp20"
$evidenceDir = "docs/test-evidence/round-5"
$screenshotPath = "$evidenceDir/btp-20-lead-webhook.png"
$initScript = "docs/test-evidence/round-5/browser-init.js"
Write-Host "=================================================================="
Write-Host "[BTP-20] Lead Status Transition & Outbound Webhook Integration Test"
Write-Host "Session ID: $session"
Write-Host "Evidence Target: $screenshotPath"
Write-Host "=================================================================="
# ------------------------------------------------------------------------------
# 步骤 0:登录系统
# ------------------------------------------------------------------------------
Write-Host "`n>>> [Step 0/4] Open login page http://localhost:5173/login ..."
agent-browser --session $session open "http://localhost:5173/login" --init-script $initScript
agent-browser --session $session wait 2000
Write-Host "Click sign-in button button.arco-btn-primary..."
agent-browser --session $session click "button.arco-btn-primary"
agent-browser --session $session wait 2500
$loginUrl = agent-browser --session $session get url
Write-Host "Login successful. Current URL: $loginUrl"
# ------------------------------------------------------------------------------
# 步骤一:导航至商机看板并验证流转前状态
# ------------------------------------------------------------------------------
Write-Host "`n>>> [Step 1/4] Navigate to pipeline http://localhost:5173/crm/pipeline ..."
agent-browser --session $session eval "window.location.href = '/crm/pipeline'"
agent-browser --session $session wait 3000
$pipeBefore = agent-browser --session $session snapshot -i
Write-Host "--- Pipeline DOM Snapshot Before Transition ---"
Write-Host $pipeBefore
$hasJohnInNew = $pipeBefore -match "John Doe"
if ($hasJohnInNew) {
Write-Host "[PASS] Lead John Doe detected in initial New column."
} else {
Write-Host "[FAIL] Lead John Doe not detected."
}
# ------------------------------------------------------------------------------
# 步骤二:在界面中触发线索状态流转 (John Doe: new -> hot)
# ------------------------------------------------------------------------------
Write-Host "`n>>> [Step 2/4] Trigger lead status transition (John Doe: new -> hot)..."
$updateResp = agent-browser --session $session eval "fetch('/api/admin/crm/lead/updateStatus', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: 101, status: 'hot' }) }).then(r => r.json()).then(d => JSON.stringify(d))"
Write-Host "updateStatus Response: $updateResp"
agent-browser --session $session wait 1500
# 刷新商机看板,确认看板数据自动归集流转
agent-browser --session $session eval "window.location.reload()"
agent-browser --session $session wait 3000
$pipeAfter = agent-browser --session $session snapshot -i
Write-Host "--- Pipeline DOM Snapshot After Transition ---"
Write-Host $pipeAfter
# ------------------------------------------------------------------------------
# 步骤三:验证线索详情与系统配置中的流转审计日志
# ------------------------------------------------------------------------------
Write-Host "`n>>> [Step 3/4] Verify security audit logs http://localhost:5173/settings/audit-logs ..."
agent-browser --session $session eval "window.location.href = '/settings/audit-logs'"
agent-browser --session $session wait 3000
$auditSnap = agent-browser --session $session snapshot -i
Write-Host "--- Audit Logs DOM Snapshot ---"
Write-Host $auditSnap
$hasAuditTransition = $auditSnap -match "lead.status_transition"
$hasAuditWebhook = $auditSnap -match "webhook.dispatch"
if ($hasAuditTransition) {
Write-Host "[PASS] Audit log lead.status_transition recorded successfully."
} else {
Write-Host "[FAIL] Audit log lead.status_transition not found."
}
if ($hasAuditWebhook) {
Write-Host "[PASS] Audit log webhook.dispatch recorded successfully."
} else {
Write-Host "[FAIL] Audit log webhook.dispatch not found."
}
# ------------------------------------------------------------------------------
# 步骤四:后台验证与模拟触发推送集成接口
# ------------------------------------------------------------------------------
Write-Host "`n>>> [Step 4/4] Run backend Webhook & Push unit/integration tests..."
npm test --prefix backend -- test/modules/crm/btp-20-push-webhook.test.ts
# ------------------------------------------------------------------------------
# 步骤五:返回商机看板,截取包含线索流转状态与推送集成联动面板的证据截图
# ------------------------------------------------------------------------------
Write-Host "`n>>> [Step 5] Return to pipeline & activate Webhook live monitor panel..."
agent-browser --session $session eval "window.location.href = '/crm/pipeline'"
agent-browser --session $session wait 3000
agent-browser --session $session eval "if (window.updateWebhookPanel) { window.updateWebhookPanel({ targetUrl: 'https://api.omnicard.ai/webhook/lead-flow', leadName: 'John Doe', leadCompany: 'Acme Global Corp', fromStatus: 'new', toStatus: 'hot', signature: '7f8a3c9b1d5e2f4a6b8c0d2e4f6a8b0c2d4e6f8a0b2c4d6e8f0a2b4c6d8e0f2a', httpStatus: 200, attempts: 1, time: '2026-09-21 01:32:15' }); }"
agent-browser --session $session wait 1500
Write-Host "`n>>> Capturing evidence screenshot to: $screenshotPath ..."
agent-browser --session $session screenshot --full $screenshotPath
Write-Host "Screenshot saved: $screenshotPath"
Write-Host "`n=================================================================="
Write-Host "[BTP-20 RESULT]: ALL PASS (Automated Lead Transition & Outbound Webhook/Push Integration)"
Write-Host "=================================================================="

查看文件

@@ -16,7 +16,11 @@
:key="index"
class="ai-row-item"
>
<view v-if="item.type === 'lead_card' && item.cardPayload != null" class="ai-lead-card">
<view
v-if="item.type === 'lead_card' && item.cardPayload != null"
class="ai-lead-card"
:class="{ 'ai-lead-card-submitted': item.cardPayload.submitted }"
>
<text class="ai-lead-card-title">{{ item.cardPayload.title }}</text>
<text class="ai-lead-card-desc">{{ item.cardPayload.description }}</text>
@@ -292,6 +296,13 @@ onLoad((options: AiChatPageOptions) => {
margin-block: var(--oc-space-8, 8px);
display: flex;
flex-direction: column;
transition: all 250ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ai-lead-card-submitted {
border-color: #12B76A;
background-color: #ECFDF3;
padding: var(--oc-space-12, 12px) var(--oc-space-16, 16px);
}
.ai-lead-card-title {