fix(activities): show form photos in the note instead of the raw file link

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-03 17:30:45 +03:30
co-authored by Cursor
parent 35d74d33ed
commit bffe174162
2 changed files with 221 additions and 4 deletions
+74 -2
View File
@@ -50,7 +50,78 @@ export function parseLeadFormNotes(raw: string | null | undefined): LeadNoteView
}
export function leadNoteFileIsImage(file: LeadNoteFile): boolean {
return file.mime.startsWith('image/') || (file.dataUrl ?? '').startsWith('data:image/');
return (
file.mime.startsWith('image/') ||
(file.dataUrl ?? '').startsWith('data:image/') ||
/\.(png|jpe?g|gif|webp)$/i.test(file.name)
);
}
export type NoteChip =
| { kind: 'text'; text: string }
| { kind: 'image'; src: string; name: string }
| { kind: 'link'; href: string; label: string };
const FILE_BLOB = /\{[^{}]*"dataUrl"\s*:\s*"data:[^"]+"[^{}]*\}/gi;
const DATA_IMAGE = /data:image\/[a-z0-9.+-]+;base64,[a-z0-9+/=]+/gi;
const HTTP_URL = /https?:\/\/[^\s<>"']+/gi;
/** متن یادداشت را به نوشته، لینک کوتاه و تصویر جدا می‌کند. */
export function noteChips(raw: string | null | undefined): NoteChip[] {
const text = raw ?? '';
if (!text.trim()) return [];
const chips: NoteChip[] = [];
const token = new RegExp(
`${FILE_BLOB.source}|${DATA_IMAGE.source}|${HTTP_URL.source}`,
'gi',
);
let cursor = 0;
for (const match of text.matchAll(token)) {
const index = match.index ?? 0;
pushText(chips, text.slice(cursor, index));
chips.push(chipForToken(match[0]));
cursor = index + match[0].length;
}
pushText(chips, text.slice(cursor));
return chips;
}
function pushText(chips: NoteChip[], value: string) {
if (!value) return;
const prev = chips[chips.length - 1];
if (prev?.kind === 'text') prev.text += value;
else chips.push({ kind: 'text', text: value });
}
function chipForToken(token: string): NoteChip {
if (token.startsWith('{')) {
const file = readFileValue(token);
if (file?.dataUrl && leadNoteFileIsImage(file)) {
return { kind: 'image', src: file.dataUrl, name: file.name };
}
if (file?.dataUrl) return { kind: 'link', href: file.dataUrl, label: file.name };
return { kind: 'text', text: file?.name || 'فایل پیوست' };
}
if (token.startsWith('data:image/')) {
return { kind: 'image', src: token, name: 'تصویر پیوست' };
}
if (isImageUrl(token)) return { kind: 'image', src: token, name: shortUrl(token) };
return { kind: 'link', href: token, label: shortUrl(token) };
}
function isImageUrl(href: string): boolean {
return /\.(png|jpe?g|gif|webp)(\?.*)?$/i.test(href);
}
function shortUrl(href: string): string {
try {
const url = new URL(href);
const file = decodeURIComponent(url.pathname.split('/').filter(Boolean).pop() ?? '');
const label = file || url.hostname;
return label.length > 42 ? `${label.slice(0, 24)}…${label.slice(-10)}` : label;
} catch {
return href.length > 42 ? `${href.slice(0, 28)}…` : href;
}
}
/** فیلد تاریخ تولد روی خود لید هست؛ جواب فرم نباید دوباره ردیف بسازد. */
@@ -111,7 +182,8 @@ function readFileValue(value: string): LeadNoteFile | null {
return asFile(JSON.parse(value));
} catch {
const name = value.match(/"name"\s*:\s*"([^"]+)"/)?.[1]?.trim();
if (name) return { name, mime: '', dataUrl: null };
const mime = value.match(/"mime"\s*:\s*"([^"]+)"/)?.[1]?.trim() ?? '';
if (name) return { name, mime, dataUrl: null };
if (value.includes('data:')) return { name: 'فایل پیوست', mime: '', dataUrl: null };
return null;
}
+147 -2
View File
@@ -25,6 +25,13 @@ import { useAuthStore } from '@/stores/auth';
import { canAssignToOthers } from '@/lib/assign-access';
import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types';
import { brand } from '@mento/shared';
import {
leadNoteFileIsImage,
noteChips,
parseLeadFormNotes,
type LeadNoteFile,
type LeadNoteRow,
} from '@/lib/lead-form-notes';
const route = useRoute();
const router = useRouter();
@@ -62,6 +69,51 @@ const moreOpen = ref(false);
const commentOpen = ref(false);
const postponeOpen = ref(false);
const noteOpen = ref(true);
const noteModel = computed(() => parseLeadFormNotes(activity.value?.description));
const noteRows = computed(() =>
noteModel.value.mode === 'answers' ? noteModel.value.rows : ([] as LeadNoteRow[]),
);
const noteExtra = computed(() =>
noteChips(
noteModel.value.mode === 'answers' ? noteModel.value.prose : activity.value?.description,
).filter(
(chip) => !(chip.kind === 'text' && chip.text.replace(/\s+/g, '').trim() === 'پاسخ‌هایفرم:'),
),
);
const recoveredImages = ref<Record<string, string>>({});
function noteImageSrc(file: LeadNoteFile): string | null {
if (!leadNoteFileIsImage(file)) return null;
return file.dataUrl || recoveredImages.value[file.name] || null;
}
watch(
[() => activity.value?.id, () => activity.value?.lead?.id, noteRows],
async ([activityId, leadId]) => {
const missing = noteRows.value
.map((row) => row.file)
.filter((file): file is LeadNoteFile => Boolean(file && !file.dataUrl && leadNoteFileIsImage(file)))
.map((file) => file.name);
recoveredImages.value = {};
if (!activityId || !leadId || !missing.length) return;
try {
const { data } = await api.get<{ notes?: string | null }>(`/leads/${leadId}`);
if (activity.value?.id !== activityId) return;
const wanted = new Set(missing);
const next: Record<string, string> = {};
for (const row of parseLeadFormNotes(data.notes).rows) {
const file = row.file;
if (file?.dataUrl && wanted.has(file.name) && leadNoteFileIsImage(file)) {
next[file.name] = file.dataUrl;
}
}
recoveredImages.value = next;
} catch {
recoveredImages.value = {};
}
},
{ immediate: true },
);
const users = ref<OrgUserDto[]>([]);
const assignUserIds = ref<string[]>([]);
@@ -565,8 +617,61 @@ onMounted(() => {
<UiIcon name="chevron" :size="16" :class="{ open: noteOpen }" />
</button>
<div v-show="noteOpen">
<p v-if="activity.description?.trim()">{{ activity.description.trim() }}</p>
<p v-else-if="!imageUrls.length" class="empty">یادداشتی ثبت نشده.</p>
<dl v-if="noteRows.length" class="note-rows">
<div v-for="(row, index) in noteRows" :key="`${row.label}-${index}`">
<dt>{{ row.label }}</dt>
<dd>
<template v-if="row.file && noteImageSrc(row.file)">
<a class="note-shot" :href="noteImageSrc(row.file)!" target="_blank" rel="noopener">
<img :src="noteImageSrc(row.file)!" :alt="row.file.name" />
</a>
<span>{{ row.file.name }}</span>
</template>
<span v-else-if="row.file">{{ row.file.name }}</span>
<template v-else>
<template v-for="(chip, chipIndex) in noteChips(row.text)" :key="chipIndex">
<a
v-if="chip.kind === 'link'"
:href="chip.href"
target="_blank"
rel="noopener"
>{{ chip.label }}</a>
<a
v-else-if="chip.kind === 'image'"
class="note-shot"
:href="chip.src"
target="_blank"
rel="noopener"
>
<img :src="chip.src" :alt="chip.name" />
</a>
<span v-else>{{ chip.text }}</span>
</template>
</template>
</dd>
</div>
</dl>
<p v-else-if="!noteExtra.length && !imageUrls.length" class="empty">یادداشتی ثبت نشده.</p>
<p v-if="noteExtra.length" class="note-extra">
<template v-for="(chip, index) in noteExtra" :key="index">
<a
v-if="chip.kind === 'link'"
:href="chip.href"
target="_blank"
rel="noopener"
>{{ chip.label }}</a>
<a
v-else-if="chip.kind === 'image'"
class="note-shot"
:href="chip.src"
target="_blank"
rel="noopener"
>
<img :src="chip.src" :alt="chip.name" />
</a>
<span v-else>{{ chip.text }}</span>
</template>
</p>
<div v-if="imageUrls.some((image) => image.kind === 'image')" class="note-photos">
<a
v-for="image in imageUrls.filter((item) => item.kind === 'image')"
@@ -993,6 +1098,46 @@ dd {
color: #94a3b8;
}
.note-rows {
margin: 0.75rem 0 0;
display: grid;
gap: 0.55rem;
}
.note-rows > div {
display: grid;
grid-template-columns: minmax(7rem, 12rem) minmax(0, 1fr);
gap: 0.45rem 0.75rem;
align-items: start;
}
.note-rows dt {
color: #64748b;
font-weight: 500;
}
.note-rows dd {
display: grid;
gap: 0.35rem;
justify-items: start;
}
.note-shot {
display: block;
max-width: 100%;
}
.note-shot img {
display: block;
width: auto;
max-width: min(280px, 100%);
max-height: 220px;
object-fit: contain;
border-radius: 0.6rem;
border: 1px solid #e2e8f0;
background: #fff;
}
.note-photos {
display: flex;
flex-wrap: wrap;