feat(mpuniapp): 实现用户端网络拦截器与 API 客户端基础契约 (Task 06)
这个提交包含在:
@@ -4,4 +4,9 @@ module.exports = {
|
||||
testMatch: ['<rootDir>/test/**/*.test.ts'],
|
||||
testPathIgnorePatterns: ['<rootDir>/test/fixtures'],
|
||||
coveragePathIgnorePatterns: ['<rootDir>/test/'],
|
||||
moduleFileExtensions: ['ts', 'uts', 'js', 'json'],
|
||||
transform: {
|
||||
'^.+\\.ts$': 'ts-jest',
|
||||
'^.+\\.uts$': '<rootDir>/test/mpuniapp/uts-transformer.js',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* OmniCard AI (CRMmp) - mpuniapp 网络拦截器与 API 客户端基础契约测试 (TDD Red -> Green)
|
||||
*
|
||||
* 严格按照 AGENTS.md 3.3、4.3 规范与 PRD 设计编写。
|
||||
*/
|
||||
|
||||
describe('OmniCard AI 用户端 mpuniapp 网络拦截器与 API 契约测试', () => {
|
||||
let storageMap: Map<string, string>;
|
||||
let toastCalls: Array<{ title: string; icon?: string }>;
|
||||
let reLaunchCalls: Array<{ url: string }>;
|
||||
let lastRequestOptions: any;
|
||||
let mockRequestResponse: {
|
||||
statusCode?: number;
|
||||
data?: any;
|
||||
failError?: any;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
storageMap = new Map();
|
||||
toastCalls = [];
|
||||
reLaunchCalls = [];
|
||||
lastRequestOptions = null;
|
||||
mockRequestResponse = {
|
||||
statusCode: 200,
|
||||
data: { code: 1000, message: 'success', data: { ok: true } },
|
||||
};
|
||||
|
||||
// 构造全局 uni 运行时 Mock 环境
|
||||
(global as any).uni = {
|
||||
getStorageSync: (key: string) => storageMap.get(key) || '',
|
||||
setStorageSync: (key: string, val: string) => storageMap.set(key, val),
|
||||
removeStorageSync: (key: string) => storageMap.delete(key),
|
||||
showToast: (options: { title: string; icon?: string }) => {
|
||||
toastCalls.push(options);
|
||||
},
|
||||
reLaunch: (options: { url: string }) => {
|
||||
reLaunchCalls.push(options);
|
||||
},
|
||||
request: (options: any) => {
|
||||
lastRequestOptions = options;
|
||||
setTimeout(() => {
|
||||
if (mockRequestResponse.failError) {
|
||||
options.fail?.(mockRequestResponse.failError);
|
||||
} else {
|
||||
options.success?.({
|
||||
statusCode: mockRequestResponse.statusCode ?? 200,
|
||||
data: mockRequestResponse.data,
|
||||
header: {},
|
||||
});
|
||||
}
|
||||
options.complete?.();
|
||||
}, 0);
|
||||
return {
|
||||
abort: jest.fn(),
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (global as any).uni;
|
||||
});
|
||||
|
||||
describe('1. 基础配置与请求拦截器 (request.uts)', () => {
|
||||
it('1.1 默认配置、baseUrl 拼接与超时时长 (10000ms)', async () => {
|
||||
// @ts-ignore
|
||||
const { request, getBaseUrl, setBaseUrl, getRequestTimeout, setRequestTimeout } = require('../../../mpuniapp/common/request.uts');
|
||||
|
||||
expect(getRequestTimeout()).toBe(10000);
|
||||
expect(getBaseUrl()).toBeDefined();
|
||||
|
||||
setBaseUrl('https://api.omnicard.ai/api');
|
||||
setRequestTimeout(8000);
|
||||
expect(getBaseUrl()).toBe('https://api.omnicard.ai/api');
|
||||
expect(getRequestTimeout()).toBe(8000);
|
||||
|
||||
// 发起相对路径请求
|
||||
await request({
|
||||
url: '/test/endpoint',
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
expect(lastRequestOptions).toBeDefined();
|
||||
expect(lastRequestOptions.url).toBe('https://api.omnicard.ai/api/test/endpoint');
|
||||
expect(lastRequestOptions.timeout).toBe(8000);
|
||||
});
|
||||
|
||||
it('1.2 自动注入 Authorization (Token) 与 x-tenant-id 请求头', async () => {
|
||||
// @ts-ignore
|
||||
const { request } = require('../../../mpuniapp/common/request.uts');
|
||||
|
||||
storageMap.set('token', 'mock_jwt_token_999');
|
||||
storageMap.set('tenant_id', 'tenant_omni_88');
|
||||
|
||||
await request({
|
||||
url: '/card/info',
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
expect(lastRequestOptions.header).toBeDefined();
|
||||
expect(lastRequestOptions.header['Authorization']).toBe('Bearer mock_jwt_token_999');
|
||||
expect(lastRequestOptions.header['x-tenant-id']).toBe('tenant_omni_88');
|
||||
});
|
||||
|
||||
it('1.3 允许显式自定义 Header 覆盖或扩展', async () => {
|
||||
// @ts-ignore
|
||||
const { request } = require('../../../mpuniapp/common/request.uts');
|
||||
|
||||
storageMap.set('token', 'default_token');
|
||||
|
||||
await request({
|
||||
url: '/custom/header',
|
||||
method: 'POST',
|
||||
header: {
|
||||
Authorization: 'CustomToken override',
|
||||
'x-custom-trace': 'trace_123',
|
||||
},
|
||||
});
|
||||
|
||||
expect(lastRequestOptions.header['Authorization']).toBe('CustomToken override');
|
||||
expect(lastRequestOptions.header['x-custom-trace']).toBe('trace_123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. 统一响应解包与错误拦截', () => {
|
||||
it('2.1 成功响应 (code === 1000) 统一自动解包返回 data 核心数据', async () => {
|
||||
// @ts-ignore
|
||||
const { request } = require('../../../mpuniapp/common/request.uts');
|
||||
|
||||
mockRequestResponse.data = {
|
||||
code: 1000,
|
||||
message: 'success',
|
||||
data: {
|
||||
cardId: 'card_1001',
|
||||
name: 'Alex Rivera',
|
||||
},
|
||||
};
|
||||
|
||||
const res = await request({
|
||||
url: '/card/info',
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
expect(res).toEqual({
|
||||
cardId: 'card_1001',
|
||||
name: 'Alex Rivera',
|
||||
});
|
||||
expect(toastCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('2.2 业务失败 (code !== 1000) 自动弹出 uni.showToast 且 Promise reject', async () => {
|
||||
// @ts-ignore
|
||||
const { request } = require('../../../mpuniapp/common/request.uts');
|
||||
|
||||
mockRequestResponse.data = {
|
||||
code: 1002,
|
||||
message: '指定微官网名片不存在或已下架',
|
||||
data: null,
|
||||
};
|
||||
|
||||
await expect(
|
||||
request({
|
||||
url: '/card/portal/info',
|
||||
method: 'GET',
|
||||
})
|
||||
).rejects.toThrow('指定微官网名片不存在或已下架');
|
||||
|
||||
expect(toastCalls).toHaveLength(1);
|
||||
expect(toastCalls[0].title).toBe('指定微官网名片不存在或已下架');
|
||||
expect(toastCalls[0].icon).toBe('none');
|
||||
});
|
||||
|
||||
it('2.3 认证失效 (code === 401 或 HTTP 401) 自动跳转登录页、清除 Token 并弹出提示', async () => {
|
||||
// @ts-ignore
|
||||
const { request } = require('../../../mpuniapp/common/request.uts');
|
||||
|
||||
storageMap.set('token', 'expired_token');
|
||||
|
||||
mockRequestResponse.data = {
|
||||
code: 401,
|
||||
message: '登录状态已失效,请重新登录',
|
||||
};
|
||||
|
||||
await expect(
|
||||
request({
|
||||
url: '/card/my-cards',
|
||||
method: 'GET',
|
||||
})
|
||||
).rejects.toThrow('登录状态已失效,请重新登录');
|
||||
|
||||
expect(storageMap.has('token')).toBe(false);
|
||||
expect(reLaunchCalls).toHaveLength(1);
|
||||
expect(reLaunchCalls[0].url).toBe('/pages/common/login');
|
||||
expect(toastCalls).toHaveLength(1);
|
||||
expect(toastCalls[0].title).toBe('登录状态已失效,请重新登录');
|
||||
});
|
||||
|
||||
it('2.4 网络请求异常 (fail 回调) 提示网络异常并 Promise reject', async () => {
|
||||
// @ts-ignore
|
||||
const { request } = require('../../../mpuniapp/common/request.uts');
|
||||
|
||||
mockRequestResponse.failError = {
|
||||
errMsg: 'request:fail timeout',
|
||||
};
|
||||
|
||||
await expect(
|
||||
request({
|
||||
url: '/timeout/test',
|
||||
method: 'GET',
|
||||
})
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(toastCalls).toHaveLength(1);
|
||||
expect(toastCalls[0].title).toContain('网络连接异常');
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. 核心 API 方法契约 (api.uts)', () => {
|
||||
it('3.1 fetchPortalCard 能够按 ID 或 Slug 正确请求微官网聚合详情', async () => {
|
||||
// @ts-ignore
|
||||
const { fetchPortalCard } = require('../../../mpuniapp/common/api.uts');
|
||||
|
||||
mockRequestResponse.data = {
|
||||
code: 1000,
|
||||
message: 'success',
|
||||
data: {
|
||||
id: 1001,
|
||||
slug: 'alex-rivera',
|
||||
name: 'Alex Rivera',
|
||||
title: 'VP of Global Business',
|
||||
company: 'OmniCard AI Global',
|
||||
bentoBlocks: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await fetchPortalCard('alex-rivera');
|
||||
|
||||
expect(lastRequestOptions.method).toBe('GET');
|
||||
expect(lastRequestOptions.url).toContain('/card/portal/info');
|
||||
expect(lastRequestOptions.data).toEqual({ id: 'alex-rivera' });
|
||||
expect(result.name).toBe('Alex Rivera');
|
||||
expect(result.slug).toBe('alex-rivera');
|
||||
});
|
||||
|
||||
it('3.2 submitLead 能够提交留资/互换名片数据并返回潜客模型', async () => {
|
||||
// @ts-ignore
|
||||
const { submitLead } = require('../../../mpuniapp/common/api.uts');
|
||||
|
||||
const leadPayload = {
|
||||
cardId: 1001,
|
||||
name: 'Tariq Mansour',
|
||||
company: 'Dubai Energy Holding',
|
||||
phone: '+971-50-1234567',
|
||||
email: 'tariq@dubaienergy.ae',
|
||||
source: 'nfc',
|
||||
notes: 'Interested in enterprise solution',
|
||||
};
|
||||
|
||||
mockRequestResponse.data = {
|
||||
code: 1000,
|
||||
message: 'success',
|
||||
data: {
|
||||
id: 'lead_9921',
|
||||
...leadPayload,
|
||||
status: 'new',
|
||||
intentScore: 85,
|
||||
},
|
||||
};
|
||||
|
||||
const lead = await submitLead(leadPayload);
|
||||
|
||||
expect(lastRequestOptions.method).toBe('POST');
|
||||
expect(lastRequestOptions.url).toContain('/crm/lead/submit');
|
||||
expect(lastRequestOptions.data).toEqual(leadPayload);
|
||||
expect(lead.id).toBe('lead_9921');
|
||||
expect(lead.status).toBe('new');
|
||||
});
|
||||
|
||||
it('3.3 recordRadarTrack 能够发送访客轨迹数据并记录', async () => {
|
||||
// @ts-ignore
|
||||
const { recordRadarTrack } = require('../../../mpuniapp/common/api.uts');
|
||||
|
||||
const trackPayload = {
|
||||
cardId: 1001,
|
||||
visitorId: 'vis_de_4432',
|
||||
touchType: 'qr',
|
||||
stayDurationMs: 65000,
|
||||
scrollDepth: 80,
|
||||
viewedBlocks: ['hero', 'case_1'],
|
||||
};
|
||||
|
||||
mockRequestResponse.data = {
|
||||
code: 1000,
|
||||
message: 'success',
|
||||
data: {
|
||||
id: 'track_881',
|
||||
...trackPayload,
|
||||
country: 'Germany',
|
||||
city: 'Frankfurt',
|
||||
},
|
||||
};
|
||||
|
||||
const track = await recordRadarTrack(trackPayload);
|
||||
|
||||
expect(lastRequestOptions.method).toBe('POST');
|
||||
expect(lastRequestOptions.url).toContain('/crm/radar/track');
|
||||
expect(lastRequestOptions.data).toEqual(trackPayload);
|
||||
expect(track.id).toBe('track_881');
|
||||
expect(track.country).toBe('Germany');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
const ts = require('typescript');
|
||||
|
||||
module.exports = {
|
||||
process(sourceText, sourcePath) {
|
||||
const result = ts.transpileModule(sourceText, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2020,
|
||||
esModuleInterop: true,
|
||||
},
|
||||
fileName: sourcePath,
|
||||
});
|
||||
return {
|
||||
code: result.outputText,
|
||||
};
|
||||
},
|
||||
};
|
||||
4
backend/typings/uts.d.ts
vendored
普通文件
4
backend/typings/uts.d.ts
vendored
普通文件
@@ -0,0 +1,4 @@
|
||||
declare module '*.uts' {
|
||||
const content: any;
|
||||
export = content;
|
||||
}
|
||||
45
mpuniapp/common/api.uts
普通文件
45
mpuniapp/common/api.uts
普通文件
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* OmniCard AI (CRMmp) - 用户端面向微官网与名片的核心 API 客户端
|
||||
* 严格遵循 AGENTS.md 规范与 uts 强类型约束
|
||||
*/
|
||||
|
||||
import {
|
||||
CardPortalDetail,
|
||||
LeadEntity,
|
||||
RadarTrack,
|
||||
SubmitLeadParams,
|
||||
RecordRadarTrackParams,
|
||||
} from './types.uts';
|
||||
import { httpGet, httpPost } from './request.uts';
|
||||
|
||||
/**
|
||||
* 获取微官网与名片公开聚合详情 (包含 Hero、Bento 块、多语言与品牌 VI)
|
||||
*
|
||||
* @param cardIdOrSlug 名片主键 ID 或自定义短链标识
|
||||
*/
|
||||
export function fetchPortalCard(cardIdOrSlug: string): Promise<CardPortalDetail> {
|
||||
return httpGet<CardPortalDetail>('/card/portal/info', {
|
||||
id: cardIdOrSlug,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交无摩擦潜客留资 / 互换名片数据
|
||||
*
|
||||
* @param leadData 潜客留资信息
|
||||
*/
|
||||
export function submitLead(leadData: SubmitLeadParams): Promise<LeadEntity> {
|
||||
return httpPost<LeadEntity>('/crm/lead/submit', leadData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录访客实时雷达轨迹足迹 (停留时长、滚动深度、浏览块等)
|
||||
*
|
||||
* @param trackData 轨迹行为数据
|
||||
*/
|
||||
export function recordRadarTrack(trackData: RecordRadarTrackParams): Promise<RadarTrack> {
|
||||
return httpPost<RadarTrack>('/crm/radar/track', trackData);
|
||||
}
|
||||
|
||||
export * from './types.uts';
|
||||
export * from './request.uts';
|
||||
207
mpuniapp/common/request.uts
普通文件
207
mpuniapp/common/request.uts
普通文件
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* OmniCard AI (CRMmp) - 用户端网络请求底层封装与统一拦截器
|
||||
* 严格遵循 AGENTS.md 规范与 uts 强类型规范,一套代码兼顾 App 与 Web/H5
|
||||
*/
|
||||
|
||||
import { ApiResponse } from './types.uts';
|
||||
|
||||
let currentBaseUrl: string = 'http://127.0.0.1:9000/api';
|
||||
// #ifdef H5
|
||||
currentBaseUrl = '/api';
|
||||
// #endif
|
||||
|
||||
let currentTimeout: number = 10000;
|
||||
|
||||
export function getBaseUrl(): string {
|
||||
return currentBaseUrl;
|
||||
}
|
||||
|
||||
export function setBaseUrl(url: string): void {
|
||||
currentBaseUrl = url;
|
||||
}
|
||||
|
||||
export function getRequestTimeout(): number {
|
||||
return currentTimeout;
|
||||
}
|
||||
|
||||
export function setRequestTimeout(timeout: number): void {
|
||||
currentTimeout = timeout;
|
||||
}
|
||||
|
||||
export function getAuthToken(): string {
|
||||
try {
|
||||
const val = uni.getStorageSync('token');
|
||||
return val != null ? String(val) : '';
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function setAuthToken(token: string): void {
|
||||
try {
|
||||
uni.setStorageSync('token', token);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
export function clearAuthToken(): void {
|
||||
try {
|
||||
uni.removeStorageSync('token');
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
export function getTenantId(): string {
|
||||
try {
|
||||
const val = uni.getStorageSync('tenant_id');
|
||||
return val != null ? String(val) : '';
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function setTenantId(tenantId: string): void {
|
||||
try {
|
||||
uni.setStorageSync('tenant_id', tenantId);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
export type RequestMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||
|
||||
export interface RequestConfig {
|
||||
url: string;
|
||||
method?: RequestMethod;
|
||||
data?: any;
|
||||
header?: Record<string, any>;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建完整的请求 URL
|
||||
*/
|
||||
function buildFullUrl(url: string): string {
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
const base = getBaseUrl().replace(/\/+$/, '');
|
||||
const path = url.startsWith('/') ? url : `/${url}`;
|
||||
return `${base}${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一处理未授权 / 401 状态
|
||||
*/
|
||||
function handleUnauthorized(message: string): void {
|
||||
clearAuthToken();
|
||||
uni.showToast({
|
||||
title: message || '登录状态已失效,请重新登录',
|
||||
icon: 'none',
|
||||
});
|
||||
uni.reLaunch({
|
||||
url: '/pages/common/login',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 网络请求底层统一封装
|
||||
*/
|
||||
export function request<T>(config: RequestConfig): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const method = config.method || 'GET';
|
||||
const fullUrl = buildFullUrl(config.url);
|
||||
const timeout = config.timeout != null ? config.timeout : getRequestTimeout();
|
||||
|
||||
// 默认请求头配置
|
||||
const headers: Record<string, any> = {
|
||||
'content-type': 'application/json',
|
||||
};
|
||||
|
||||
// 自动挂载 Authorization 与 x-tenant-id
|
||||
const token = getAuthToken();
|
||||
if (token) {
|
||||
headers['Authorization'] = token.startsWith('Bearer ') ? token : `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const tenantId = getTenantId();
|
||||
if (tenantId) {
|
||||
headers['x-tenant-id'] = tenantId;
|
||||
}
|
||||
|
||||
// 显式传入的 header 具有更高优先级
|
||||
if (config.header) {
|
||||
Object.assign(headers, config.header);
|
||||
}
|
||||
|
||||
uni.request({
|
||||
url: fullUrl,
|
||||
method: method as any,
|
||||
data: config.data,
|
||||
header: headers,
|
||||
timeout: timeout,
|
||||
success: (res: any) => {
|
||||
const statusCode = res.statusCode != null ? res.statusCode : 200;
|
||||
const resBody = res.data;
|
||||
|
||||
// 1. 处理 HTTP 401 或业务 code 401
|
||||
if (statusCode === 401 || (resBody != null && resBody.code === 401)) {
|
||||
const msg = (resBody && resBody.message) ? resBody.message : '登录状态已失效,请重新登录';
|
||||
handleUnauthorized(msg);
|
||||
reject(new Error(msg));
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 处理非 200 的 HTTP 异常状态
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
const errorMsg = (resBody && resBody.message) ? resBody.message : `网络请求错误 (${statusCode})`;
|
||||
uni.showToast({
|
||||
title: errorMsg,
|
||||
icon: 'none',
|
||||
});
|
||||
reject(new Error(errorMsg));
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 处理 Cool-Admin 标准业务状态 (code === 1000 为成功)
|
||||
if (resBody != null && typeof resBody === 'object' && 'code' in resBody) {
|
||||
const apiRes = resBody as ApiResponse<T>;
|
||||
if (apiRes.code === 1000) {
|
||||
resolve(apiRes.data !== undefined ? apiRes.data : (apiRes as unknown as T));
|
||||
} else {
|
||||
const failMsg = apiRes.message || '请求处理失败';
|
||||
uni.showToast({
|
||||
title: failMsg,
|
||||
icon: 'none',
|
||||
});
|
||||
reject(new Error(failMsg));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. 其它直接返回的数据格式
|
||||
resolve(resBody as T);
|
||||
},
|
||||
fail: (err: any) => {
|
||||
const errMsg = (err && err.errMsg) ? err.errMsg : '网络连接异常,请重试';
|
||||
uni.showToast({
|
||||
title: '网络连接异常,请重试',
|
||||
icon: 'none',
|
||||
});
|
||||
reject(new Error(errMsg));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function httpGet<T>(url: string, data?: any, header?: Record<string, any>): Promise<T> {
|
||||
return request<T>({ url, method: 'GET', data, header });
|
||||
}
|
||||
|
||||
export function httpPost<T>(url: string, data?: any, header?: Record<string, any>): Promise<T> {
|
||||
return request<T>({ url, method: 'POST', data, header });
|
||||
}
|
||||
|
||||
export function httpPut<T>(url: string, data?: any, header?: Record<string, any>): Promise<T> {
|
||||
return request<T>({ url, method: 'PUT', data, header });
|
||||
}
|
||||
|
||||
export function httpDelete<T>(url: string, data?: any, header?: Record<string, any>): Promise<T> {
|
||||
return request<T>({ url, method: 'DELETE', data, header });
|
||||
}
|
||||
119
mpuniapp/common/types.uts
普通文件
119
mpuniapp/common/types.uts
普通文件
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* OmniCard AI (CRMmp) - 用户端跨端核心数据契约与 TypeScript / UTS 类型体系
|
||||
* 严格遵循 AGENTS.md 规范与出海 SaaS 契约,与 backend / admin 镜像对齐
|
||||
*/
|
||||
|
||||
export type CardStatus = 'draft' | 'published' | 'archived';
|
||||
|
||||
export type BentoBlockType = 'hero' | 'case' | 'article' | 'social' | 'video' | 'form';
|
||||
|
||||
export type BentoColSpan = 1 | 2;
|
||||
|
||||
export type BentoRowSpan = 1 | 2;
|
||||
|
||||
export interface BentoBlockPublic {
|
||||
id: number | string;
|
||||
cardId: number | string;
|
||||
type: BentoBlockType;
|
||||
colSpan: BentoColSpan;
|
||||
rowSpan: BentoRowSpan;
|
||||
sortOrder: number;
|
||||
title?: string;
|
||||
content?: string;
|
||||
mediaUrl?: string;
|
||||
linkUrl?: string;
|
||||
metaJson?: string | Record<string, any>;
|
||||
}
|
||||
|
||||
export interface BrandKit {
|
||||
logoUrl: string;
|
||||
primaryColor: string;
|
||||
}
|
||||
|
||||
export interface CardPortalDetail {
|
||||
id: number | string;
|
||||
slug?: string;
|
||||
name: string;
|
||||
title: string;
|
||||
company: string;
|
||||
slogan?: string;
|
||||
avatarUrl?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
whatsapp?: string;
|
||||
linkedin?: string;
|
||||
timezone?: string;
|
||||
audioPronunciationUrl?: string;
|
||||
themeCode?: string;
|
||||
status: CardStatus;
|
||||
bentoBlocks: BentoBlockPublic[];
|
||||
brandKit?: BrandKit;
|
||||
cachedAt?: number;
|
||||
}
|
||||
|
||||
export type LeadStatus = 'new' | 'contacted' | 'hot' | 'won' | 'archived';
|
||||
|
||||
export type LeadSource = 'nfc' | 'qr' | 'link' | 'ai_chat';
|
||||
|
||||
export interface LeadEntity {
|
||||
id: string | number;
|
||||
tenantId?: string | number;
|
||||
cardId: string | number;
|
||||
visitorId?: string;
|
||||
name: string;
|
||||
company?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
status: LeadStatus;
|
||||
intentScore: number;
|
||||
source: LeadSource;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface SubmitLeadParams {
|
||||
cardId: string | number;
|
||||
name: string;
|
||||
company?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
source?: string;
|
||||
notes?: string;
|
||||
visitorId?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export type TouchType = 'nfc' | 'qr' | 'link';
|
||||
|
||||
export interface RadarTrack {
|
||||
id: string | number;
|
||||
tenantId?: string | number;
|
||||
cardId: string | number;
|
||||
visitorId: string;
|
||||
ip?: string;
|
||||
country?: string;
|
||||
city?: string;
|
||||
touchType: TouchType;
|
||||
stayDurationMs: number;
|
||||
scrollDepth: number;
|
||||
viewedBlocks: string[];
|
||||
createdAt?: string | Date;
|
||||
}
|
||||
|
||||
export interface RecordRadarTrackParams {
|
||||
cardId: string | number;
|
||||
visitorId: string;
|
||||
touchType?: string;
|
||||
stayDurationMs?: number;
|
||||
scrollDepth?: number;
|
||||
viewedBlocks?: string[];
|
||||
ip?: string;
|
||||
country?: string;
|
||||
city?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户