feat: 完善出海商机管道快速录入、RTL服务端首屏渲染与AI顾问留资动效闭环
这个提交包含在:
@@ -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 & 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 时应提示名片已下线或不存在', () => {
|
||||
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 {
|
||||
|
||||
在新工单中引用
屏蔽一个用户