补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。 Co-authored-by: Cursor <cursoragent@cursor.com>
833 行
20 KiB
Plaintext
833 行
20 KiB
Plaintext
/**
|
||
* 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',
|
||
});
|
||
},
|
||
});
|
||
}
|