diff --git a/backend/test/mpuniapp/owner-card-edit.test.ts b/backend/test/mpuniapp/owner-card-edit.test.ts index cd8e823..9eaced2 100644 --- a/backend/test/mpuniapp/owner-card-edit.test.ts +++ b/backend/test/mpuniapp/owner-card-edit.test.ts @@ -289,6 +289,7 @@ describe('OmniCard AI 名片主极速编辑页契约 (edit.uvue)', () => { expect(combined.includes('fetchOwnerCardInfo') || combined.includes('loadCard')).toBe( true ); + expect(logicSource.includes('export {') && logicSource.includes('fetchOwnerCardInfo')).toBe(true); expect(combined.includes('buildUpdatePayload')).toBe(true); expect(pageSource.includes('v-model="state.name"')).toBe(true); expect(pageSource.includes('v-model="state.title"')).toBe(true); diff --git a/backend/test/mpuniapp/owner-lead-detail.test.ts b/backend/test/mpuniapp/owner-lead-detail.test.ts index a2b4682..db4a869 100644 --- a/backend/test/mpuniapp/owner-lead-detail.test.ts +++ b/backend/test/mpuniapp/owner-lead-detail.test.ts @@ -93,7 +93,12 @@ type UseLeadDetailModule = { next: string | null | undefined, attachments?: string[] ) => string; + parseNoteAttachments: (rawNote: string | null | undefined) => { + cleanText: string; + imageUrls: string[]; + }; formatWhatsAppUrl: (phone: string | null | undefined) => string; + createLeadDetailState?: () => Record; generateAiIcebreaker: ( lead: LeadEntity | null, tracks: RadarTrack[], @@ -310,6 +315,57 @@ describe('OmniCard AI 名片主潜客详情业务逻辑 (useLeadDetail.uts)', () expect(state.saving).toBe(false); expect(updateOwnerLeadStatus).toHaveBeenCalledTimes(1); }); + + it('3.4 submitLeadNote 在 nextNote 为空但有 attachments 时仍可提交附件', async () => { + const { submitLeadNote } = loadUseLeadDetail() as UseLeadDetailModule & { + submitLeadNote: ( + state: Record, + api?: SaveNoteApi + ) => Promise; + }; + const httpPost = jest.fn( + async (url: string, data: Record) => { + expect(url).toBe('/admin/crm/lead/update'); + expect(data.id).toBe(21); + expect(data.notes).toContain('https://cdn.example.com/card.png'); + return buildLead({ notes: String(data.notes) }); + } + ); + const state: Record = { + leadId: 21, + existingNotes: 'Initial meeting', + nextNote: '', + attachments: ['https://cdn.example.com/card.png'], + saving: false, + errorMessage: '', + }; + const ok = await submitLeadNote(state, { httpPost }); + expect(ok).toBe(true); + expect(state.existingNotes).toContain('https://cdn.example.com/card.png'); + expect(state.attachments).toEqual([]); + expect(httpPost).toHaveBeenCalledTimes(1); + }); + + it('3.5 submitLeadNote 在 nextNote 为空且无 attachments 时返回 false', async () => { + const { submitLeadNote } = loadUseLeadDetail() as UseLeadDetailModule & { + submitLeadNote: ( + state: Record, + api?: SaveNoteApi + ) => Promise; + }; + const httpPost = jest.fn(); + const state: Record = { + leadId: 21, + existingNotes: '', + nextNote: ' ', + attachments: [], + saving: false, + errorMessage: '', + }; + const ok = await submitLeadNote(state, { httpPost }); + expect(ok).toBe(false); + expect(httpPost).not.toHaveBeenCalled(); + }); }); describe('4. resolveLeadDetailText 只增文案', () => { @@ -327,10 +383,13 @@ describe('OmniCard AI 名片主潜客详情业务逻辑 (useLeadDetail.uts)', () expect(resolveLeadDetailText('whatsappAction').length).toBeGreaterThan(0); expect(resolveLeadDetailText('saveContactAction').length).toBeGreaterThan(0); expect(resolveLeadDetailText('generateAiAction').length).toBeGreaterThan(0); + expect(resolveLeadDetailText('applyScriptAction').length).toBeGreaterThan(0); + expect(resolveLeadDetailText('noteSavedToast')).toBe('备忘已保存'); + expect(resolveLeadDetailText('notesRequiredToast')).toBe('请输入备忘或上传附件'); }); }); - describe('5. 销售触达与 AI 赋能函数', () => { + describe('5. 国际销售触达与 AI 赋能函数', () => { it('5.1 formatWhatsAppUrl 清洗国际号码并生成 wa.me 链接', () => { const { formatWhatsAppUrl } = loadUseLeadDetail(); @@ -367,6 +426,35 @@ describe('OmniCard AI 名片主潜客详情业务逻辑 (useLeadDetail.uts)', () expect(resWithAttach).toContain('Follow-up call agreed'); expect(resWithAttach).toContain('https://cdn.example.com/card.jpg'); }); + + it('5.4 parseNoteAttachments 提取历史备忘中的文本与图片附件链接', () => { + const { parseNoteAttachments } = loadUseLeadDetail(); + + const sample = 'Discussed custom pricing\n[https://cdn.example.com/card1.jpg https://cdn.example.com/card2.jpg]'; + const parsed = parseNoteAttachments(sample); + + expect(parsed.cleanText).toBe('Discussed custom pricing'); + expect(parsed.imageUrls).toHaveLength(2); + expect(parsed.imageUrls[0]).toBe('https://cdn.example.com/card1.jpg'); + expect(parsed.imageUrls[1]).toBe('https://cdn.example.com/card2.jpg'); + + const plain = parseNoteAttachments('Just plain text'); + expect(plain.cleanText).toBe('Just plain text'); + expect(plain.imageUrls).toHaveLength(0); + + const empty = parseNoteAttachments(''); + expect(empty.cleanText).toBe(''); + expect(empty.imageUrls).toHaveLength(0); + }); + + it('7.3 createLeadDetailState 必须初始化 phone, email, source 字段', () => { + const { createLeadDetailState } = loadUseLeadDetail(); + expect(typeof createLeadDetailState).toBe('function'); + const state = createLeadDetailState!(); + expect(state.phone).toBe(''); + expect(state.email).toBe(''); + expect(state.source).toBe(''); + }); }); }); @@ -419,11 +507,12 @@ describe('OmniCard AI 名片主潜客详情页契约 (leads-detail.uvue)', () => expect(pageSource.includes('#F7F8FA')).toBe(true); expect(pageSource.includes('12px')).toBe(true); expect(pageSource.includes('16px')).toBe(true); - expect( - pageSource.includes('navigateBack') || logicSource.includes('navigateBack') - ).toBe(true); + expect(pageSource.includes('notes-input')).toBe(true); + expect(pageSource.includes('font-size: 16px')).toBe(true); + expect(pageSource.includes('lead-cta-inner')).toBe(true); + expect(pageSource.includes('max-width: 480px')).toBe(true); + expect(pageSource.includes("'contactSaved'")).toBe(false); }); - it('无 emoji、无 any,模板无硬编码中文主文案', () => { const pageSource = fs.readFileSync(DETAIL_PAGE, 'utf8'); const logicSource = fs.readFileSync(DETAIL_UTS, 'utf8'); diff --git a/mpuniapp/pages/owner/card/useCardEdit.uts b/mpuniapp/pages/owner/card/useCardEdit.uts index b07502f..803cc98 100644 --- a/mpuniapp/pages/owner/card/useCardEdit.uts +++ b/mpuniapp/pages/owner/card/useCardEdit.uts @@ -7,6 +7,8 @@ import { CardProfile } from '../../../common/types.uts'; import { fetchOwnerCardInfo, updateOwnerCard } from '../../../common/api.uts'; import { getCurrentLocale, getOmniText } from '../../../common/locale.uts'; +export { fetchOwnerCardInfo, updateOwnerCard }; + export type CardEditCopyKey = | 'heading' | 'name' diff --git a/mpuniapp/pages/owner/leads-detail.uvue b/mpuniapp/pages/owner/leads-detail.uvue index 4be8e68..e17259b 100644 --- a/mpuniapp/pages/owner/leads-detail.uvue +++ b/mpuniapp/pages/owner/leads-detail.uvue @@ -145,6 +145,9 @@ {{ copyScriptLabel }} + + {{ applyScriptLabel }} + @@ -200,9 +203,19 @@ - + {{ historyLabel }} - {{ state.existingNotes }} + {{ parsedNotes.cleanText }} + + + @@ -263,6 +276,7 @@ import { formatWhatsAppUrl, generateAiIcebreaker, goBackFromLeadDetail, + parseNoteAttachments, refreshLeadDetail, resolveLeadDetailText, saveContactToDevice, @@ -317,6 +331,7 @@ const whatsappLabel = ref(resolveLeadDetailText('whatsappAction')); const saveContactLabel = ref(resolveLeadDetailText('saveContactAction')); const generateAiLabel = ref(resolveLeadDetailText('generateAiAction')); const copyScriptLabel = ref(resolveLeadDetailText('copyScriptAction')); +const applyScriptLabel = ref(resolveLeadDetailText('applyScriptAction')); const pageRtlClass = computed((): string => { return isRtl(getCurrentLocale()) ? 'lead-rtl' : ''; @@ -333,7 +348,7 @@ const emptyMessage = computed((): string => { }); const leadInitial = computed((): string => { - const trimmed = state.name.trim(); + const trimmed = (state.name != null ? state.name : '').trim(); if (trimmed === '') { return '?'; } @@ -362,15 +377,15 @@ const statusPillClass = computed((): string => { }); const phoneText = computed((): string => { - return state.phone.trim(); + return (state.phone != null ? state.phone : '').trim(); }); const emailText = computed((): string => { - return state.email.trim(); + return (state.email != null ? state.email : '').trim(); }); const sourceText = computed((): string => { - const src = state.source.trim(); + const src = (state.source != null ? state.source : '').trim(); if (src === 'nfc') { return 'NFC'; } @@ -387,7 +402,7 @@ const sourceText = computed((): string => { }); const hasWhatsApp = computed((): boolean => { - return formatWhatsAppUrl(state.phone) !== ''; + return formatWhatsAppUrl(state.phone != null ? state.phone : '') !== ''; }); const hasOutreachOptions = computed((): boolean => { @@ -474,7 +489,7 @@ async function handleSaveContact(): Promise { }); if (ok) { uni.showToast({ - title: resolveLeadDetailText('contactSaved'), + title: resolveLeadDetailText('savedContactToast'), icon: 'success', }); } @@ -489,21 +504,9 @@ async function handleStageChange(targetStatus: string): Promise { } function handleGenerateAiScript(): void { + if (state.aiGenerating) return; state.aiGenerating = true; - const script = generateAiIcebreaker( - { - id: state.id, - cardId: state.cardId, - name: state.name, - company: state.company, - status: state.status, - intentScore: state.intentScore, - source: state.source, - }, - state.tracks, - getCurrentLocale() - ); - state.aiScript = script; + state.aiScript = generateAiIcebreaker(state.lead, state.tracks, getCurrentLocale()); state.aiGenerating = false; } @@ -521,7 +524,37 @@ async function handleSave(): Promise { await submitLeadNote(state); } +const parsedNotes = computed((): ParsedNoteResult => { + return parseNoteAttachments(state.existingNotes); +}); + +function handleApplyAiScript(): void { + if (state.aiScript === '') { + return; + } + state.nextNote = + state.nextNote.trim() !== '' + ? `${state.nextNote}\n${state.aiScript}` + : state.aiScript; + uni.showToast({ + title: resolveLeadDetailText('scriptCopiedToast'), + icon: 'none', + }); +} + +function handlePreviewImage(current: string, urls: string[]): void { + uni.previewImage({ + current, + urls, + }); +} + onLoad((options: LeadDetailPageOptions) => { + try { + uni.setNavigationBarTitle({ + title: resolveLeadDetailText('leadTitle'), + }); + } catch (e) {} applyLeadDetailOptions(state, options); refreshLeadDetail(state); }); @@ -568,6 +601,30 @@ onShow(() => { flex-direction: column; overflow-y: auto; -webkit-overflow-scrolling: touch; + max-width: 480px; + width: 100%; + margin: 0 auto; +} + +.ai-apply-btn { + margin-inline-start: 8px; + background-color: var(--oc-primary-soft, #EBF5FF); +} + +.history-images-grid { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 8px; + margin-block-start: 8px; +} + +.history-thumb { + width: 60px; + height: 60px; + border-radius: 8px; + background-color: #EBF1F7; + border: 1px solid var(--oc-border, #EBF1F7); } /* Empty State */ diff --git a/mpuniapp/pages/owner/team/overview.uvue b/mpuniapp/pages/owner/team/overview.uvue index d283224..eaa737e 100644 --- a/mpuniapp/pages/owner/team/overview.uvue +++ b/mpuniapp/pages/owner/team/overview.uvue @@ -1,91 +1,181 @@