文件
OmniCard/mpuniapp/pages/owner/useLeadDetail.uts
wikiboss 62e0ae4435 feat: 同步管理端、用户端与后端业务,并接通线上 Web 接口
补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 18:01:47 +08:00

833 行
20 KiB
Plaintext

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

/**
* OmniCard AI (CRMmp) - 名片主潜客详情与跟进备忘业务驱动层
* 拉线索详情 + 足迹分页,备忘只追加不覆盖
*/
import {
LeadEntity,
LeadStatus,
OwnerPageData,
OwnerPageQuery,
RadarTrack,
CardProfile,
} from '../../common/types.uts';
import {
fetchOwnerLeadInfo,
fetchOwnerRadarPage,
updateOwnerLeadStatus,
} from '../../common/api.uts';
import { httpPost } from '../../common/request.uts';
import { getCurrentLocale, getOmniText } from '../../common/locale.uts';
import { statusLabel } from './useLeads.uts';
import { formatDepth, formatLocation, formatStay } from './useRadar.uts';
import { saveToContacts } from '../../common/vcard-export.uts';
export type LeadDetailCopyKey =
| 'title'
| 'score'
| 'timeline'
| 'notes'
| 'notesPlaceholder'
| 'save'
| 'saving'
| 'back'
| 'empty'
| 'missingId'
| 'error'
| 'loading'
| 'stay'
| 'depth'
| 'aiScript'
| 'comingSoon'
| 'uploadHint'
| 'stageTitle'
| 'historyTitle'
| 'stageUpdated'
| 'copiedToast'
| 'sourceLabel'
| 'whatsappAction'
| 'saveContactAction'
| 'generateAiAction'
| 'copyScriptAction'
| 'applyScriptAction'
| 'scriptCopiedToast'
| 'savedContactToast'
| 'leadTitle'
| 'aiScriptTitle'
| 'callFailed'
| 'noteSavedToast'
| 'notesRequiredToast';
export type LeadDetailPageOptions = {
id?: string;
};
export type LoadDetailApi = {
fetchOwnerLeadInfo?: (id: string | number) => Promise<LeadEntity>;
fetchOwnerRadarPage?: (
query: OwnerPageQuery
) => Promise<OwnerPageData<RadarTrack>>;
};
export type LoadDetailResult = {
lead: LeadEntity | null;
tracks: RadarTrack[];
empty: boolean;
errorMessage: string;
};
export type SaveNoteInput = {
id: string | number;
notes?: string;
next: string;
status?: LeadStatus;
attachments?: string[];
};
export type SaveNoteApi = {
httpPost?: (
url: string,
data: Record<string, string | number>
) => Promise<LeadEntity>;
updateOwnerLeadStatus?: (
id: string | number,
status: LeadStatus
) => Promise<LeadEntity>;
};
export type SaveNoteResult = {
ok: boolean;
code?: number;
lead: LeadEntity | null;
errorMessage: string;
};
export type LeadDetailState = {
rawId: string;
leadId: string | number | null;
lead: LeadEntity | null;
name: string;
company: string;
phone: string;
email: string;
source: string;
status: string;
intentScore: number;
existingNotes: string;
nextNote: string;
attachments: string[];
tracks: RadarTrack[];
loading: boolean;
saving: boolean;
empty: boolean;
missingId: boolean;
errorMessage: string;
aiScript: string;
aiGenerating: boolean;
};
const DEFAULT_PAGE = 1;
const DEFAULT_SIZE = 20;
function isZhLocale(): boolean {
return getCurrentLocale() === 'zh-CN';
}
/**
* 页面文案:标题 / 意向 / 轨迹 / 保存走词典,缺 id 与占位走本地词典
*/
export function resolveLeadDetailText(key: LeadDetailCopyKey): string {
const locale = getCurrentLocale();
if (key === 'title') {
return getOmniText(locale, 'crm.follow_up');
}
if (key === 'score') {
return getOmniText(locale, 'radar.lead_score');
}
if (key === 'timeline') {
return getOmniText(locale, 'radar.visitor_trace');
}
if (key === 'notes') {
return getOmniText(locale, 'crm.follow_up');
}
if (key === 'save') {
return getOmniText(locale, 'common.save');
}
if (key === 'empty') {
return getOmniText(locale, 'common.empty');
}
if (key === 'error') {
return getOmniText(locale, 'common.error');
}
if (key === 'loading') {
return getOmniText(locale, 'common.loading');
}
const zh = isZhLocale();
if (key === 'notesPlaceholder') {
return zh ? '追加一条跟进备忘' : 'Add a follow-up note';
}
if (key === 'back') {
return zh ? '返回列表' : 'Back to list';
}
if (key === 'missingId') {
return zh ? '缺少潜客编号' : 'Lead ID is missing';
}
if (key === 'stay') {
return zh ? '停留' : 'Stay';
}
if (key === 'depth') {
return zh ? '滚动深度' : 'Scroll depth';
}
if (key === 'aiScript') {
return zh
? '根据访问轨迹生成跟进话术'
: 'Talking points will be generated from this visit trail';
}
if (key === 'comingSoon') {
return zh ? '即将提供' : 'Coming soon';
}
if (key === 'uploadHint') {
return zh ? '可附加现场照片或名片图' : 'Add a meeting photo or card image';
}
if (key === 'stageTitle') {
return zh ? '商机阶段' : 'Pipeline Stage';
}
if (key === 'historyTitle') {
return zh ? '历史跟进纪要' : 'Follow-up History';
}
if (key === 'stageUpdated') {
return zh ? '阶段已更新' : 'Stage updated';
}
if (key === 'copiedToast') {
return zh ? '已复制到剪贴板' : 'Copied to clipboard';
}
if (key === 'sourceLabel') {
return zh ? '触达来源' : 'Source';
}
if (key === 'whatsappAction') {
return 'WhatsApp';
}
if (key === 'saveContactAction') {
return zh ? '保存到通讯录' : 'Save Contact';
}
if (key === 'generateAiAction') {
return zh ? '生成跟进话术' : 'Generate Script';
}
if (key === 'copyScriptAction') {
return zh ? '复制话术' : 'Copy Script';
}
if (key === 'applyScriptAction') {
return zh ? '填入备忘' : 'Apply to Note';
}
if (key === 'scriptCopiedToast') {
return zh ? '话术已复制' : 'Script copied';
}
if (key === 'savedContactToast') {
return zh ? '联系人已保存' : 'Contact saved';
}
if (key === 'leadTitle') {
return zh ? '潜客档案' : 'Lead Profile';
}
if (key === 'aiScriptTitle') {
return zh ? '建议跟进话术' : 'Follow-up Suggestion';
}
if (key === 'callFailed') {
return zh ? '无法拨打电话' : 'Unable to place the call';
}
if (key === 'noteSavedToast') {
return zh ? '备忘已保存' : 'Note saved';
}
if (key === 'notesRequiredToast') {
return zh ? '请输入备忘或上传附件' : 'Please enter note or upload attachment';
}
if (key === 'saving') {
return zh ? '保存中' : 'Saving';
}
return '';
}
export function statusLabelOf(status: string): string {
return statusLabel(status);
}
export function formatTrackStay(ms: number | null | undefined): string {
return formatStay(ms);
}
export function formatTrackDepth(depth: number | null | undefined): string {
return formatDepth(depth);
}
export function formatTrackPlace(country?: string, city?: string): string {
return formatLocation(country, city);
}
/**
* 解析为正整数。非正整数返回 null
*/
export function parseLeadId(
raw: string | number | null | undefined
): string | number | null {
if (raw == null) {
return null;
}
if (typeof raw === 'number') {
if (raw <= 0) {
return null;
}
if (Math.floor(raw) !== raw) {
return null;
}
return raw;
}
const text = String(raw).trim();
if (text === '') {
return null;
}
if (!/^[1-9]\d*$/.test(text)) {
return null;
}
return Number(text);
}
/**
* 拼接备忘:新内容追加在旧内容后,空输入不覆盖
*/
export function appendNote(
existing: string | null | undefined,
next: string | null | undefined
): string {
const prev = existing != null ? String(existing).trim() : '';
const extra = next != null ? String(next).trim() : '';
if (prev === '') {
return extra;
}
if (extra === '') {
return prev;
}
return `${prev}\n${extra}`;
}
/**
* 清洗手机号并生成 WhatsApp 直达链接 (wa.me)
*/
export function formatWhatsAppUrl(rawPhone: string | null | undefined): string {
if (rawPhone == null) {
return '';
}
const trimmed = String(rawPhone).trim();
if (trimmed === '') {
return '';
}
let digits = trimmed.replace(/[^0-9]/g, '');
if (digits.startsWith('00')) {
digits = digits.slice(2);
}
if (digits === '') {
return '';
}
return `https://wa.me/${digits}`;
}
function toastNone(title: string): void {
uni.showToast({
title,
icon: 'none',
});
}
/**
* 打开外链:优先 window.open(H5),否则复制链接(对齐 useLogin openOrCopyAuthUrl)
*/
function openExternalUrl(url: string): boolean {
const safeUrl = url != null ? url.trim() : '';
if (safeUrl === '') {
return false;
}
let opened = false;
try {
if (typeof window !== 'undefined' && typeof window.open === 'function') {
window.open(safeUrl, '_blank');
opened = true;
}
} catch (_e) {}
if (opened) {
return true;
}
try {
uni.setClipboardData({
data: safeUrl,
showToast: true,
});
return true;
} catch (_e) {
return false;
}
}
/**
* 有合法电话号时调 uni.makePhoneCall;无号码 toast comingSoon。
* 不伪造成功:fail 回调提示 callFailed。
*/
export function callLeadPhone(phone: string | null | undefined): boolean {
const trimmed = phone != null ? String(phone).trim() : '';
const digits = trimmed.replace(/[^0-9]/g, '');
if (trimmed === '' || digits === '') {
toastNone(resolveLeadDetailText('comingSoon'));
return false;
}
uni.makePhoneCall({
phoneNumber: trimmed,
fail: () => {
toastNone(resolveLeadDetailText('callFailed'));
},
});
return true;
}
/**
* 有 WhatsApp/电话号码时打开 wa.me;无号码 toast comingSoon,不打开空链接。
*/
export function openLeadWhatsApp(
phoneOrWhatsApp: string | null | undefined
): boolean {
const waUrl = formatWhatsAppUrl(phoneOrWhatsApp);
if (waUrl === '') {
toastNone(resolveLeadDetailText('comingSoon'));
return false;
}
return openExternalUrl(waUrl);
}
/**
* 跟进话术生成器 (基于客户姓名、公司、渠道与意向板块)
*/
export function generateAiIcebreaker(
lead: LeadEntity | null,
tracks: RadarTrack[],
locale?: string
): string {
if (lead == null) {
return '';
}
const currentLoc =
locale != null && locale !== '' ? locale : getCurrentLocale();
const isZh = currentLoc === 'zh-CN';
const name = lead.name.trim();
const company =
lead.company != null && lead.company.trim() !== ''
? lead.company.trim()
: '';
const source =
lead.source != null ? String(lead.source).toUpperCase() : 'OMNICARD';
let keyTopic = '';
if (tracks.length > 0) {
for (const track of tracks) {
if (track.viewedBlocks != null && track.viewedBlocks.length > 0) {
keyTopic = track.viewedBlocks[0];
break;
}
}
}
if (isZh) {
const topicPart = keyTopic !== '' ? `【${keyTopic}】等` : '';
return `您好 ${name},看到您此前通过 ${source} 关注了我们的数字名片${topicPart}内容。请问您近期是否有相关的业务合作或定制需求?期待与您交流。`;
}
const firstWord = name.split(' ')[0];
const companyPart = company !== '' ? ` for ${company}` : '';
return `Hi ${firstWord}, thanks for connecting via ${source} and reviewing our profile. Please let me know if you have any questions or collaboration interests${companyPart}. Look forward to connecting!`;
}
/**
* 结构化拼接备忘:将附件图片持久化嵌入备忘文本,避免数据丢失
*/
export function appendStructuredNote(
existing: string | null | undefined,
next: string | null | undefined,
attachments?: string[]
): string {
const prev = existing != null ? String(existing).trim() : '';
let extra = next != null ? String(next).trim() : '';
if (attachments != null && attachments.length > 0) {
const attachList = attachments.filter((url) => url.trim() !== '');
if (attachList.length > 0) {
const attachText = attachList.join(' ');
extra = extra !== '' ? `${extra}\n[${attachText}]` : `[${attachText}]`;
}
}
if (prev === '') {
return extra;
}
if (extra === '') {
return prev;
}
return `${prev}\n${extra}`;
}
export type ParsedNoteResult = {
cleanText: string;
imageUrls: string[];
};
/**
* 结构化解析历史备忘,分离纯文本说明与附件图片 URL 列表
*/
export function parseNoteAttachments(
rawNote: string | null | undefined
): ParsedNoteResult {
if (rawNote == null || rawNote.trim() === '') {
return { cleanText: '', imageUrls: [] };
}
const trimmed = rawNote.trim();
const bracketRegex = /\[(.*?)\]/g;
const imageUrls: string[] = [];
let match: RegExpExecArray | null = null;
while ((match = bracketRegex.exec(trimmed)) !== null) {
const inside = match[1];
if (inside.includes('http://') || inside.includes('https://')) {
const parts = inside.split(/\s+/);
for (const part of parts) {
const p = part.trim();
if (p.startsWith('http://') || p.startsWith('https://')) {
imageUrls.push(p);
}
}
}
}
const cleanText = trimmed.replace(bracketRegex, '').trim();
return { cleanText, imageUrls };
}
export type DeviceContactDraft = {
id?: string | number;
name: string;
company?: string;
phone?: string;
email?: string;
};
/**
* 保存到手机系统通讯录 (深度复用通用 RFC 6350 / vCard 引擎)
*/
export async function saveContactToDevice(
lead: DeviceContactDraft | null
): Promise<boolean> {
if (lead == null) {
return false;
}
const card: CardProfile = {
id: lead.id,
name: lead.name,
company: lead.company != null ? lead.company : '',
phone: lead.phone != null ? lead.phone : '',
email: lead.email != null ? lead.email : '',
};
return saveToContacts(card);
}
function readErrorMessage(err: unknown): string {
if (err instanceof Error && err.message !== '') {
return err.message;
}
return resolveLeadDetailText('error');
}
function parseTrackList(
payload: OwnerPageData<RadarTrack> | null | undefined
): RadarTrack[] {
if (payload == null || payload.list == null) {
return [];
}
return payload.list;
}
/**
* 拉取潜客详情与足迹。可注入 fetchOwnerLeadInfo / fetchOwnerRadarPage
*/
export async function loadDetail(
id: string | number | null | undefined,
api?: LoadDetailApi
): Promise<LoadDetailResult> {
const leadId = parseLeadId(id);
if (leadId == null) {
return {
lead: null,
tracks: [],
empty: true,
errorMessage: '',
};
}
const fetchInfo =
api != null && api.fetchOwnerLeadInfo != null
? api.fetchOwnerLeadInfo
: fetchOwnerLeadInfo;
const fetchRadar =
api != null && api.fetchOwnerRadarPage != null
? api.fetchOwnerRadarPage
: fetchOwnerRadarPage;
try {
const lead = await fetchInfo(leadId);
const visitorId = lead.visitorId != null ? String(lead.visitorId) : '';
const radarQuery: OwnerPageQuery = {
page: DEFAULT_PAGE,
size: DEFAULT_SIZE,
keyWord: visitorId,
cardId: lead.cardId,
};
try {
const payload = await fetchRadar(radarQuery);
const tracks = parseTrackList(payload);
return {
lead,
tracks,
empty: false,
errorMessage: '',
};
} catch (radarErr) {
return {
lead,
tracks: [],
empty: false,
errorMessage: readErrorMessage(radarErr),
};
}
} catch (err) {
return {
lead: null,
tracks: [],
empty: true,
errorMessage: readErrorMessage(err),
};
}
}
/**
* 更新 notes(支持附件结构化嵌入):POST /admin/crm/lead/update { id, notes }
* 仅 Cool-Admin code === 1000(http 层已解包成功)视为保存成功
*/
export async function saveNote(
input: SaveNoteInput,
api?: SaveNoteApi
): Promise<SaveNoteResult> {
const notes = appendStructuredNote(
input.notes,
input.next,
input.attachments
);
const post =
api != null && api.httpPost != null ? api.httpPost : httpPost;
const payload: Record<string, string | number> = {
id: input.id,
notes,
};
try {
const lead = await post<LeadEntity>('/admin/crm/lead/update', payload);
return {
ok: true,
code: 1000,
lead,
errorMessage: '',
};
} catch (err) {
return {
ok: false,
code: undefined,
lead: null,
errorMessage: readErrorMessage(err),
};
}
}
export function createLeadDetailState(): LeadDetailState {
return {
rawId: '',
leadId: null,
lead: null,
name: '',
company: '',
phone: '',
email: '',
source: '',
status: '',
intentScore: 0,
existingNotes: '',
nextNote: '',
attachments: [],
tracks: [],
loading: false,
saving: false,
empty: false,
missingId: false,
errorMessage: '',
aiScript: '',
aiGenerating: false,
};
}
export function applyLeadDetailOptions(
state: LeadDetailState,
options: LeadDetailPageOptions | null
): boolean {
if (options == null || options.id == null) {
state.rawId = '';
state.leadId = null;
state.missingId = true;
state.empty = true;
return false;
}
const parsed = parseLeadId(options.id);
state.rawId = String(options.id).trim();
state.leadId = parsed;
state.missingId = parsed == null;
state.empty = parsed == null;
return parsed != null;
}
function applyLeadToState(state: LeadDetailState, lead: LeadEntity): void {
state.lead = lead;
state.name = lead.name;
state.company = lead.company != null ? lead.company : '';
state.phone = lead.phone != null ? lead.phone : '';
state.email = lead.email != null ? lead.email : '';
state.source = lead.source != null ? String(lead.source) : '';
state.status = lead.status;
state.intentScore = lead.intentScore;
state.existingNotes = lead.notes != null ? lead.notes : '';
}
export function applyLoadResult(
state: LeadDetailState,
result: LoadDetailResult
): void {
if (result.lead != null) {
applyLeadToState(state, result.lead);
state.empty = false;
state.missingId = false;
} else {
state.lead = null;
state.empty = true;
}
state.tracks = result.tracks;
state.errorMessage = result.errorMessage;
}
export async function refreshLeadDetail(
state: LeadDetailState,
api?: LoadDetailApi
): Promise<LoadDetailResult> {
if (state.missingId || state.leadId == null) {
const skipped: LoadDetailResult = {
lead: null,
tracks: [],
empty: true,
errorMessage: '',
};
applyLoadResult(state, skipped);
state.missingId = true;
return skipped;
}
if (state.loading) {
return {
lead: state.lead,
tracks: state.tracks,
empty: state.empty,
errorMessage: state.errorMessage,
};
}
state.loading = true;
state.errorMessage = '';
const result = await loadDetail(state.leadId, api);
applyLoadResult(state, result);
state.loading = false;
return result;
}
export async function submitLeadNote(
state: LeadDetailState,
api?: SaveNoteApi
): Promise<boolean> {
if (state.leadId == null || state.saving) {
return false;
}
const next = state.nextNote.trim();
const hasAttachments = state.attachments != null && state.attachments.length > 0;
if (next === '' && !hasAttachments) {
return false;
}
state.saving = true;
state.errorMessage = '';
const result = await saveNote(
{
id: state.leadId,
notes: state.existingNotes,
next,
status: state.lead != null ? state.lead.status : undefined,
attachments: state.attachments,
},
api
);
// 仅接口 code === 1000 才清空输入并标成已保存
if (result.ok && result.code === 1000 && result.lead != null) {
applyLeadToState(state, result.lead);
state.nextNote = '';
state.attachments = [];
state.saving = false;
return true;
}
state.saving = false;
state.errorMessage = result.errorMessage;
return false;
}
export async function updateLeadStage(
state: LeadDetailState,
newStatus: LeadStatus,
api?: SaveNoteApi
): Promise<boolean> {
if (state.leadId == null || state.saving) {
return false;
}
const prevStatus = state.status;
state.status = newStatus;
state.saving = true;
state.errorMessage = '';
try {
const updateFn =
api != null && api.updateOwnerLeadStatus != null
? api.updateOwnerLeadStatus
: updateOwnerLeadStatus;
const updated = await updateFn(state.leadId, newStatus);
applyLeadToState(state, updated);
state.saving = false;
return true;
} catch (err) {
state.status = prevStatus;
state.saving = false;
state.errorMessage = readErrorMessage(err);
return false;
}
}
export function goBackFromLeadDetail(): void {
uni.navigateBack({
delta: 1,
fail: () => {
uni.switchTab({
url: '/pages/owner/leads',
});
},
});
}