445 行
10 KiB
Plaintext
445 行
10 KiB
Plaintext
/**
|
||
* OmniCard AI (CRMmp) - 名片主潜客详情与跟进备忘业务驱动层
|
||
* 拉线索详情 + 足迹分页,备忘只追加不覆盖
|
||
*/
|
||
|
||
import {
|
||
LeadEntity,
|
||
LeadStatus,
|
||
OwnerPageData,
|
||
OwnerPageQuery,
|
||
RadarTrack,
|
||
} from '../../common/types.uts';
|
||
import {
|
||
fetchOwnerLeadInfo,
|
||
fetchOwnerRadarPage,
|
||
} 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';
|
||
|
||
export type LeadDetailCopyKey =
|
||
| 'title'
|
||
| 'score'
|
||
| 'timeline'
|
||
| 'notes'
|
||
| 'notesPlaceholder'
|
||
| 'save'
|
||
| 'back'
|
||
| 'empty'
|
||
| 'missingId'
|
||
| 'error'
|
||
| 'loading'
|
||
| 'stay'
|
||
| 'depth';
|
||
|
||
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;
|
||
};
|
||
|
||
export type SaveNoteApi = {
|
||
httpPost?: (
|
||
url: string,
|
||
data: Record<string, string | number>
|
||
) => Promise<LeadEntity>;
|
||
updateOwnerLeadStatus?: (
|
||
id: string | number,
|
||
status: LeadStatus
|
||
) => Promise<LeadEntity>;
|
||
};
|
||
|
||
export type LeadDetailState = {
|
||
rawId: string;
|
||
leadId: string | number | null;
|
||
lead: LeadEntity | null;
|
||
name: string;
|
||
company: string;
|
||
status: string;
|
||
intentScore: number;
|
||
existingNotes: string;
|
||
nextNote: string;
|
||
attachments: string[];
|
||
tracks: RadarTrack[];
|
||
loading: boolean;
|
||
saving: boolean;
|
||
empty: boolean;
|
||
missingId: boolean;
|
||
errorMessage: string;
|
||
};
|
||
|
||
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';
|
||
}
|
||
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}`;
|
||
}
|
||
|
||
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 }
|
||
*/
|
||
export async function saveNote(
|
||
input: SaveNoteInput,
|
||
api?: SaveNoteApi
|
||
): Promise<LeadEntity> {
|
||
const notes = appendNote(input.notes, input.next);
|
||
const post =
|
||
api != null && api.httpPost != null ? api.httpPost : httpPost;
|
||
const payload: Record<string, string | number> = {
|
||
id: input.id,
|
||
notes,
|
||
};
|
||
return post<LeadEntity>('/admin/crm/lead/update', payload);
|
||
}
|
||
|
||
export function createLeadDetailState(): LeadDetailState {
|
||
return {
|
||
rawId: '',
|
||
leadId: null,
|
||
lead: null,
|
||
name: '',
|
||
company: '',
|
||
status: '',
|
||
intentScore: 0,
|
||
existingNotes: '',
|
||
nextNote: '',
|
||
attachments: [],
|
||
tracks: [],
|
||
loading: false,
|
||
saving: false,
|
||
empty: false,
|
||
missingId: false,
|
||
errorMessage: '',
|
||
};
|
||
}
|
||
|
||
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.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();
|
||
if (next === '') {
|
||
return false;
|
||
}
|
||
state.saving = true;
|
||
state.errorMessage = '';
|
||
try {
|
||
const saved = await saveNote(
|
||
{
|
||
id: state.leadId,
|
||
notes: state.existingNotes,
|
||
next,
|
||
status: state.lead != null ? state.lead.status : undefined,
|
||
},
|
||
api
|
||
);
|
||
applyLeadToState(state, saved);
|
||
state.nextNote = '';
|
||
state.saving = false;
|
||
return true;
|
||
} catch (err) {
|
||
state.saving = false;
|
||
state.errorMessage = readErrorMessage(err);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
export function goBackFromLeadDetail(): void {
|
||
uni.navigateBack({
|
||
delta: 1,
|
||
fail: () => {
|
||
uni.reLaunch({
|
||
url: '/pages/owner/leads',
|
||
});
|
||
},
|
||
});
|
||
}
|