feat: 完善出海商机管道快速录入、RTL服务端首屏渲染与AI顾问留资动效闭环
这个提交包含在:
@@ -10,6 +10,7 @@ import {
|
|||||||
Spin,
|
Spin,
|
||||||
Tag,
|
Tag,
|
||||||
Typography,
|
Typography,
|
||||||
|
Modal,
|
||||||
} from '@arco-design/web-react';
|
} from '@arco-design/web-react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import useLocale from '@/utils/useLocale';
|
import useLocale from '@/utils/useLocale';
|
||||||
@@ -20,6 +21,7 @@ import locale from './locale';
|
|||||||
const DEFAULT_PAGE = 1;
|
const DEFAULT_PAGE = 1;
|
||||||
const BATCH_SIZE = 100;
|
const BATCH_SIZE = 100;
|
||||||
const PAGE_API = '/api/admin/crm/lead/page';
|
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';
|
const UPDATE_STATUS_API = '/api/admin/crm/lead/updateStatus';
|
||||||
|
|
||||||
interface PipelineFilterForm {
|
interface PipelineFilterForm {
|
||||||
@@ -266,6 +268,47 @@ function CrmPipelinePage() {
|
|||||||
[t]
|
[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 (
|
return (
|
||||||
<Card title={t['menu.crm.pipeline']}>
|
<Card title={t['menu.crm.pipeline']}>
|
||||||
<Form form={form} layout="inline" onSubmit={handleRefresh}>
|
<Form form={form} layout="inline" onSubmit={handleRefresh}>
|
||||||
@@ -302,9 +345,11 @@ function CrmPipelinePage() {
|
|||||||
extra={leads.length}
|
extra={leads.length}
|
||||||
>
|
>
|
||||||
{leads.length === 0 ? (
|
{leads.length === 0 ? (
|
||||||
<Typography.Text type="secondary">
|
<div style={{ textAlign: 'center', padding: '16px 0' }}>
|
||||||
{t['crmPipeline.column.empty']}
|
<Typography.Text type="secondary">
|
||||||
</Typography.Text>
|
{t['crmPipeline.column.empty']}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
leads.map((lead) => {
|
leads.map((lead) => {
|
||||||
const source = isLeadSource(lead.source)
|
const source = isLeadSource(lead.source)
|
||||||
@@ -312,7 +357,7 @@ function CrmPipelinePage() {
|
|||||||
: 'link';
|
: 'link';
|
||||||
const company = lead.company?.trim();
|
const company = lead.company?.trim();
|
||||||
return (
|
return (
|
||||||
<Card key={lead.id} size="small">
|
<Card key={lead.id} size="small" style={{ marginBottom: 8 }}>
|
||||||
<Typography.Title heading={6}>
|
<Typography.Title heading={6}>
|
||||||
{lead.name}
|
{lead.name}
|
||||||
</Typography.Title>
|
</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>
|
</Card>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Grid.Row>
|
</Grid.Row>
|
||||||
</Spin>
|
</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>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ const i18n = {
|
|||||||
'crmPipeline.source.link': 'Link',
|
'crmPipeline.source.link': 'Link',
|
||||||
'crmPipeline.source.ai_chat': 'AI chat',
|
'crmPipeline.source.ai_chat': 'AI chat',
|
||||||
'crmPipeline.column.empty': 'No opportunities in this stage.',
|
'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.request.fail': 'Unable to load the pipeline',
|
||||||
'crmPipeline.update.fail': 'Unable to update opportunity status',
|
'crmPipeline.update.fail': 'Unable to update opportunity status',
|
||||||
},
|
},
|
||||||
@@ -46,6 +53,13 @@ const i18n = {
|
|||||||
'crmPipeline.source.link': '链接',
|
'crmPipeline.source.link': '链接',
|
||||||
'crmPipeline.source.ai_chat': 'AI 对话',
|
'crmPipeline.source.ai_chat': 'AI 对话',
|
||||||
'crmPipeline.column.empty': '此阶段暂无商机。',
|
'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.request.fail': '商机看板加载失败',
|
||||||
'crmPipeline.update.fail': '商机状态更新失败',
|
'crmPipeline.update.fail': '商机状态更新失败',
|
||||||
},
|
},
|
||||||
@@ -71,6 +85,13 @@ const i18n = {
|
|||||||
'crmPipeline.source.link': 'رابط',
|
'crmPipeline.source.link': 'رابط',
|
||||||
'crmPipeline.source.ai_chat': 'محادثة الذكاء الاصطناعي',
|
'crmPipeline.source.ai_chat': 'محادثة الذكاء الاصطناعي',
|
||||||
'crmPipeline.column.empty': 'لا توجد فرص في هذه المرحلة.',
|
'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.request.fail': 'تعذر تحميل مسار الفرص',
|
||||||
'crmPipeline.update.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(content.includes('crmPipeline.status.archived'), '必须包含归档状态文案');
|
||||||
assert.ok(!/\bany\b/.test(content), 'locale 禁止使用 any');
|
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>
|
return `<!DOCTYPE html>
|
||||||
<html lang="${escapeHtml(lang)}">
|
<html lang="${escapeHtml(lang)}"${isRtl ? ' dir="rtl"' : ''}>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|||||||
@@ -204,6 +204,22 @@ describe('Point 29-B: 社交爬虫探测与微官网短链服务端渲染集成
|
|||||||
expect(html).toContain('Hack & Co "Global"');
|
expect(html).toContain('Hack & Co "Global"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 时应提示名片已下线或不存在', () => {
|
it('生成 404 兜底 HTML 时应提示名片已下线或不存在', () => {
|
||||||
const notFoundHtml = cardSeoService.renderNotFoundHtml('名片不存在或已下线');
|
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
|
false
|
||||||
);
|
);
|
||||||
expect((pageSource.match(/primary-cta-btn/g) || []).length).toBeGreaterThan(0);
|
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"
|
:key="index"
|
||||||
class="ai-row-item"
|
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-title">{{ item.cardPayload.title }}</text>
|
||||||
<text class="ai-lead-card-desc">{{ item.cardPayload.description }}</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);
|
margin-block: var(--oc-space-8, 8px);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 {
|
.ai-lead-card-title {
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户