feat(activities): attach voice and files on activity notes

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-02 16:34:03 +03:30
co-authored by Cursor
parent 43437b0280
commit 06977d27ed
9 changed files with 1009 additions and 117 deletions
+55 -6
View File
@@ -57,7 +57,7 @@ const loading = ref(true);
const saving = ref(false);
const error = ref('');
const activity = ref<ActivityDto | null>(null);
const imageUrls = ref<Array<{ id: string; name: string; url: string }>>([]);
const imageUrls = ref<Array<{ id: string; name: string; url: string; kind: 'image' | 'audio' | 'file' }>>([]);
const moreOpen = ref(false);
const commentOpen = ref(false);
const postponeOpen = ref(false);
@@ -174,15 +174,22 @@ function clearImageUrls() {
async function loadActivityImages(row: ActivityDto) {
clearImageUrls();
const next: Array<{ id: string; name: string; url: string }> = [];
const next: Array<{ id: string; name: string; url: string; kind: 'image' | 'audio' | 'file' }> = [];
for (const image of row.images ?? []) {
const mime = image.mimeType.toLowerCase();
const kind = mime.startsWith('audio/') ? 'audio' : mime.startsWith('image/') ? 'image' : 'file';
try {
const { data } = await api.get<Blob>(`/activities/${row.id}/images/${image.id}`, {
responseType: 'blob',
});
next.push({ id: image.id, name: image.fileName, url: URL.createObjectURL(data) });
next.push({
id: image.id,
name: image.fileName,
url: URL.createObjectURL(data),
kind,
});
} catch {
/* عکس در دسترس نیست */
/* فایل در دسترس نیست */
}
}
imageUrls.value = next;
@@ -560,11 +567,31 @@ onMounted(() => {
<div v-show="noteOpen">
<p v-if="activity.description?.trim()">{{ activity.description.trim() }}</p>
<p v-else-if="!imageUrls.length" class="empty">یادداشتی ثبت نشده.</p>
<div v-if="imageUrls.length" class="note-photos">
<a v-for="image in imageUrls" :key="image.id" :href="image.url" target="_blank" rel="noopener">
<div v-if="imageUrls.some((image) => image.kind === 'image')" class="note-photos">
<a
v-for="image in imageUrls.filter((item) => item.kind === 'image')"
:key="image.id"
:href="image.url"
target="_blank"
rel="noopener"
>
<img :src="image.url" :alt="image.name" />
</a>
</div>
<div v-if="imageUrls.some((image) => image.kind === 'audio')" class="note-voices">
<audio
v-for="image in imageUrls.filter((item) => item.kind === 'audio')"
:key="image.id"
:src="image.url"
controls
preload="metadata"
/>
</div>
<ul v-if="imageUrls.some((image) => image.kind === 'file')" class="note-files">
<li v-for="image in imageUrls.filter((item) => item.kind === 'file')" :key="image.id">
<a :href="image.url" :download="image.name">{{ image.name }}</a>
</li>
</ul>
</div>
</article>
@@ -967,6 +994,28 @@ dd {
border: 1px solid #e2e8f0;
}
.note-voices {
display: grid;
gap: 0.45rem;
margin-top: 0.75rem;
}
.note-voices audio {
width: 100%;
}
.note-files {
list-style: none;
margin: 0.75rem 0 0;
padding: 0;
display: grid;
gap: 0.35rem;
}
.note-files a {
color: #0f766e;
}
.dock {
position: fixed;
inset-inline: 0.85rem;
+369 -36
View File
@@ -15,7 +15,8 @@ import {
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
import { appendSpokenText } from '@/lib/spoken-text';
import { FormFileTooLargeError, prepareFormFieldFile } from '@/lib/form-file-compress';
import { prepareActivityAttachment } from '@/lib/activity-attachment';
import { startVoiceRecorder, type VoiceRecorder } from '@/lib/voice-note';
import UiIcon from '@/components/UiIcon.vue';
import { brand } from '@mento/shared';
import UserAvatar from '@/components/UserAvatar.vue';
@@ -105,7 +106,21 @@ const savedPhotos = ref<SavedPhoto[]>([]);
const photoBusy = ref(false);
const photoError = ref('');
const createdActivityId = ref('');
const photoInput = ref<HTMLInputElement | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
const VOICE_LIMIT = 5;
type PendingVoice = { localId: string; name: string; url: string; file: File };
type SavedVoice = ActivityImageDto & { previewUrl: string };
const pendingVoices = ref<PendingVoice[]>([]);
const savedVoices = ref<SavedVoice[]>([]);
const voiceBusy = ref(false);
const voiceError = ref('');
const recording = ref(false);
const recordSeconds = ref(0);
type PendingFile = { localId: string; name: string; file: File };
type SavedFile = ActivityImageDto;
const pendingFiles = ref<PendingFile[]>([]);
const savedFiles = ref<SavedFile[]>([]);
let voiceRecorder: VoiceRecorder | null = null;
const dueDate = ref('');
const dueTime = ref('10:30');
const durationMinutes = ref(30);
@@ -655,6 +670,23 @@ function toggleTeam(teamId: string, userIds: string[]) {
toggleGroup('team', teamId, userIds);
}
function clearVoices() {
voiceRecorder?.cancel();
voiceRecorder = null;
recording.value = false;
recordSeconds.value = 0;
for (const voice of pendingVoices.value) URL.revokeObjectURL(voice.url);
pendingVoices.value = [];
for (const voice of savedVoices.value) {
if (voice.previewUrl.startsWith('blob:')) URL.revokeObjectURL(voice.previewUrl);
}
savedVoices.value = [];
pendingFiles.value = [];
savedFiles.value = [];
voiceError.value = '';
voiceBusy.value = false;
}
function clearPhotos() {
pendingPhotos.value = [];
for (const photo of savedPhotos.value) {
@@ -663,55 +695,82 @@ function clearPhotos() {
savedPhotos.value = [];
photoError.value = '';
photoBusy.value = false;
clearVoices();
}
async function loadSavedPhotos(activity: ActivityDto) {
clearPhotos();
const images = activity.images ?? [];
const loaded: SavedPhoto[] = [];
const loadedPhotos: SavedPhoto[] = [];
const loadedVoices: SavedVoice[] = [];
const loadedFiles: SavedFile[] = [];
for (const image of images) {
const mime = image.mimeType.toLowerCase();
if (!mime.startsWith('image/') && !mime.startsWith('audio/')) {
loadedFiles.push(image);
continue;
}
let previewUrl = '';
try {
const { data } = await api.get<Blob>(`/activities/${activity.id}/images/${image.id}`, {
responseType: 'blob',
});
loaded.push({ ...image, previewUrl: URL.createObjectURL(data) });
previewUrl = URL.createObjectURL(data);
} catch {
loaded.push({ ...image, previewUrl: '' });
previewUrl = '';
}
if (mime.startsWith('audio/')) loadedVoices.push({ ...image, previewUrl });
else loadedPhotos.push({ ...image, previewUrl });
}
savedPhotos.value = loaded;
savedPhotos.value = loadedPhotos;
savedVoices.value = loadedVoices;
savedFiles.value = loadedFiles;
}
async function onPhotosPicked(event: Event) {
const FILE_LIMIT = 8;
async function onFilesPicked(event: Event) {
const input = event.target as HTMLInputElement;
const files = Array.from(input.files ?? []);
input.value = '';
if (!files.length) return;
photoError.value = '';
voiceError.value = '';
for (const file of files) {
if (pendingPhotos.value.length + savedPhotos.value.length >= PHOTO_LIMIT) {
photoError.value = `حداکثر ${PHOTO_LIMIT} عکس برای هر فعالیت.`;
break;
}
if (!file.type.startsWith('image/')) {
photoError.value = 'فقط عکس می‌توانید اضافه کنید.';
continue;
}
photoBusy.value = true;
try {
const prepared = await prepareFormFieldFile(file);
pendingPhotos.value = [
...pendingPhotos.value,
{
localId: crypto.randomUUID(),
name: prepared.name,
dataUrl: prepared.dataUrl,
file: prepared.file,
},
];
} catch (e) {
photoError.value =
e instanceof FormFileTooLargeError ? PHOTO_TOO_LARGE : 'فشرده‌سازی عکس ناموفق بود.';
const prepared = await prepareActivityAttachment(file);
if (prepared.kind === 'image') {
if (pendingPhotos.value.length + savedPhotos.value.length >= PHOTO_LIMIT) {
photoError.value = `حداکثر ${toFaDigits(PHOTO_LIMIT)} عکس برای هر فعالیت.`;
continue;
}
pendingPhotos.value = [
...pendingPhotos.value,
{ localId: crypto.randomUUID(), name: prepared.name, dataUrl: prepared.previewUrl, file: prepared.file },
];
} else if (prepared.kind === 'audio') {
if (voiceCount() >= VOICE_LIMIT) {
URL.revokeObjectURL(prepared.previewUrl);
voiceError.value = `حداکثر ${toFaDigits(VOICE_LIMIT)} صوت برای هر فعالیت.`;
continue;
}
pendingVoices.value = [
...pendingVoices.value,
{ localId: crypto.randomUUID(), name: prepared.name, url: prepared.previewUrl, file: prepared.file },
];
} else {
if (pendingFiles.value.length + savedFiles.value.length >= FILE_LIMIT) {
photoError.value = `حداکثر ${toFaDigits(FILE_LIMIT)} فایل برای هر فعالیت.`;
continue;
}
pendingFiles.value = [
...pendingFiles.value,
{ localId: crypto.randomUUID(), name: prepared.name, file: prepared.file },
];
}
} catch (error) {
photoError.value = error instanceof Error ? error.message : 'افزودن فایل ناموفق بود.';
} finally {
photoBusy.value = false;
}
@@ -731,6 +790,116 @@ async function removeSavedPhoto(photo: SavedPhoto) {
savedPhotos.value = savedPhotos.value.filter((item) => item.id !== photo.id);
}
function formatVoiceClock(total: number) {
const minutes = Math.floor(total / 60);
const seconds = total % 60;
return toFaDigits(`${minutes}:${String(seconds).padStart(2, '0')}`);
}
function formatVoiceBytes(bytes: number) {
if (bytes >= 1024 * 1024) return `${toFaDigits((bytes / (1024 * 1024)).toFixed(1))} مگابایت`;
if (bytes >= 1024) return `${toFaDigits(Math.round(bytes / 1024))} کیلوبایت`;
return `${toFaDigits(bytes)} بایت`;
}
function voiceCount() {
return pendingVoices.value.length + savedVoices.value.length;
}
function addPendingVoice(file: File) {
if (voiceCount() >= VOICE_LIMIT) {
voiceError.value = `حداکثر ${toFaDigits(VOICE_LIMIT)} صوت برای هر فعالیت.`;
return;
}
pendingVoices.value = [
...pendingVoices.value,
{ localId: crypto.randomUUID(), name: file.name, url: URL.createObjectURL(file), file },
];
}
async function toggleRecording() {
voiceError.value = '';
if (voiceRecorder) {
const handle = voiceRecorder;
voiceRecorder = null;
recording.value = false;
try {
addPendingVoice(await handle.stop());
} catch (error) {
voiceError.value = error instanceof Error ? error.message : 'ضبط صوت ناموفق بود.';
}
return;
}
if (voiceCount() >= VOICE_LIMIT) {
voiceError.value = `حداکثر ${toFaDigits(VOICE_LIMIT)} صوت برای هر فعالیت.`;
return;
}
try {
recordSeconds.value = 0;
voiceRecorder = await startVoiceRecorder({
onTick: (seconds) => {
recordSeconds.value = seconds;
},
onAutoStop: (file) => {
voiceRecorder = null;
recording.value = false;
addPendingVoice(file);
},
});
recording.value = true;
} catch (error) {
voiceError.value = error instanceof Error ? error.message : 'ضبط صوت ناموفق بود.';
}
}
function removePendingVoice(localId: string) {
const voice = pendingVoices.value.find((item) => item.localId === localId);
if (voice) URL.revokeObjectURL(voice.url);
pendingVoices.value = pendingVoices.value.filter((item) => item.localId !== localId);
}
async function removeSavedVoice(voice: SavedVoice) {
const activityId = id.value || createdActivityId.value;
if (!activityId) return;
await api.delete(`/activities/${activityId}/images/${voice.id}`);
if (voice.previewUrl.startsWith('blob:')) URL.revokeObjectURL(voice.previewUrl);
savedVoices.value = savedVoices.value.filter((item) => item.id !== voice.id);
}
function removePendingFile(localId: string) {
pendingFiles.value = pendingFiles.value.filter((item) => item.localId !== localId);
}
async function removeSavedFile(file: SavedFile) {
const activityId = id.value || createdActivityId.value;
if (!activityId) return;
await api.delete(`/activities/${activityId}/images/${file.id}`);
savedFiles.value = savedFiles.value.filter((item) => item.id !== file.id);
}
async function uploadPendingFiles(activityId: string) {
const queue = [...pendingFiles.value];
for (const file of queue) {
const form = new FormData();
form.append('file', file.file, file.name);
const { data } = await api.post<ActivityImageDto>(`/activities/${activityId}/images`, form);
pendingFiles.value = pendingFiles.value.filter((item) => item.localId !== file.localId);
savedFiles.value = [...savedFiles.value, data];
}
}
async function uploadPendingVoices(activityId: string) {
const queue = [...pendingVoices.value];
for (const voice of queue) {
const form = new FormData();
form.append('file', voice.file, voice.name);
const { data } = await api.post<ActivityImageDto>(`/activities/${activityId}/images`, form);
URL.revokeObjectURL(voice.url);
pendingVoices.value = pendingVoices.value.filter((item) => item.localId !== voice.localId);
savedVoices.value = [...savedVoices.value, { ...data, previewUrl: URL.createObjectURL(voice.file) }];
}
}
async function uploadPendingPhotos(activityId: string) {
const queue = [...pendingPhotos.value];
for (const photo of queue) {
@@ -743,9 +912,14 @@ async function uploadPendingPhotos(activityId: string) {
}
onUnmounted(() => {
voiceRecorder?.cancel();
for (const photo of savedPhotos.value) {
if (photo.previewUrl.startsWith('blob:')) URL.revokeObjectURL(photo.previewUrl);
}
for (const voice of pendingVoices.value) URL.revokeObjectURL(voice.url);
for (const voice of savedVoices.value) {
if (voice.previewUrl.startsWith('blob:')) URL.revokeObjectURL(voice.previewUrl);
}
});
function validate() {
@@ -805,6 +979,8 @@ async function submit(next: 'detail' | 'new' = 'detail') {
});
}
await uploadPendingPhotos(savedId);
await uploadPendingVoices(savedId);
await uploadPendingFiles(savedId);
if (props.embedded) {
emit('saved');
return;
@@ -1265,20 +1441,39 @@ watch([id, isNew], () => void load(), { immediate: true });
:rows="4"
placeholder="جزئیات تماس، نتیجه جلسه یا یادداشت مرتبط را بنویسید…"
/>
<div class="photo-row">
<button type="button" class="photo-add" :disabled="photoBusy" @click="photoInput?.click()">
<UiIcon name="camera" :size="16" />
{{ photoBusy ? 'در حال کم‌حجم کردن عکس…' : 'افزودن عکس' }}
<div class="attach-row">
<button
type="button"
class="icon-tool"
aria-label="پیوست"
title="پیوست"
:disabled="photoBusy || recording"
@click="fileInput?.click()"
>
<UiIcon name="clip" :size="16" />
</button>
<button
type="button"
class="icon-tool"
:class="{ recording }"
:disabled="voiceBusy"
:aria-label="recording ? `توقف ضبط پیام صوتی ${formatVoiceClock(recordSeconds)}` : 'ضبط پیام صوتی'"
:title="recording ? `توقف ضبط ${formatVoiceClock(recordSeconds)}` : 'ضبط پیام صوتی'"
@click="toggleRecording"
>
<UiIcon name="mic" :size="16" />
</button>
<span v-if="recording" class="rec-time">{{ formatVoiceClock(recordSeconds) }}</span>
<input
ref="photoInput"
ref="fileInput"
class="photo-file"
type="file"
accept="image/*"
multiple
@change="onPhotosPicked"
@change="onFilesPicked"
/>
<small class="hint">عکس خودکار کم‌حجم می‌شود تا زیر ۲۰۰ کیلوبایت بماند.</small>
<small class="hint">
عکس زیر ۲۰۰ کیلوبایت و صوت مثل پیام‌رسان کم‌حجم می‌شود. فایل‌های دیگر تا ۱۰ مگابایت؛ همه در سقف حجم حساب می‌شوند.
</small>
</div>
<p v-if="photoError" class="error">{{ photoError }}</p>
<div v-if="pendingPhotos.length || savedPhotos.length" class="photo-grid">
@@ -1291,6 +1486,33 @@ watch([id, isNew], () => void load(), { immediate: true });
<button type="button" aria-label="حذف عکس" @click="removePendingPhoto(photo.localId)">×</button>
</figure>
</div>
<p v-if="voiceError" class="error">{{ voiceError }}</p>
<div v-if="pendingVoices.length || savedVoices.length" class="voice-list">
<div v-for="voice in savedVoices" :key="voice.id" class="voice-item">
<audio v-if="voice.previewUrl" :src="voice.previewUrl" controls preload="metadata" />
<span>{{ formatVoiceBytes(voice.sizeBytes) }}</span>
<button type="button" aria-label="حذف صوت" @click="removeSavedVoice(voice)">×</button>
</div>
<div v-for="voice in pendingVoices" :key="voice.localId" class="voice-item">
<audio :src="voice.url" controls preload="metadata" />
<span>{{ formatVoiceBytes(voice.file.size) }}</span>
<button type="button" aria-label="حذف صوت" @click="removePendingVoice(voice.localId)">×</button>
</div>
</div>
<ul v-if="pendingFiles.length || savedFiles.length" class="file-list">
<li v-for="file in savedFiles" :key="file.id">
<UiIcon name="document" :size="15" />
<span>{{ file.fileName }}</span>
<small>{{ formatVoiceBytes(file.sizeBytes) }}</small>
<button type="button" aria-label="حذف فایل" @click="removeSavedFile(file)">×</button>
</li>
<li v-for="file in pendingFiles" :key="file.localId">
<UiIcon name="document" :size="15" />
<span>{{ file.name }}</span>
<small>{{ formatVoiceBytes(file.file.size) }}</small>
<button type="button" aria-label="حذف فایل" @click="removePendingFile(file.localId)">×</button>
</li>
</ul>
<label for="activity-tags" class="tags-label">هشتگ / برچسب</label>
<UiInput
id="activity-tags"
@@ -2129,6 +2351,79 @@ watch([id, isNew], () => void load(), { immediate: true });
resize: vertical;
}
.attach-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem;
}
.icon-tool {
width: 36px;
height: 36px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
color: #0f766e;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.icon-tool.recording {
border-color: #fecaca;
background: #fef2f2;
color: #b91c1c;
}
.icon-tool:disabled {
opacity: 0.6;
cursor: progress;
}
.rec-time {
color: #b91c1c;
font-size: 0.82rem;
font-weight: 700;
}
.file-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.35rem;
}
.file-list li {
display: flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
}
.file-list span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-list small {
color: var(--muted);
white-space: nowrap;
}
.file-list button {
margin-inline-start: auto;
width: 1.4rem;
height: 1.4rem;
border: 0;
border-radius: 999px;
background: #e2e8f0;
cursor: pointer;
}
.photo-row {
display: flex;
flex-wrap: wrap;
@@ -2155,6 +2450,44 @@ watch([id, isNew], () => void load(), { immediate: true });
cursor: progress;
}
.photo-add.recording {
border-color: #fecaca;
background: #fef2f2;
color: #b91c1c;
}
.voice-list {
display: grid;
gap: 0.45rem;
}
.voice-item {
display: flex;
align-items: center;
gap: 0.45rem;
}
.voice-item audio {
flex: 1;
min-width: 0;
height: 2.25rem;
}
.voice-item span {
color: var(--muted);
font-size: 0.78rem;
white-space: nowrap;
}
.voice-item button {
width: 1.4rem;
height: 1.4rem;
border: 0;
border-radius: 999px;
background: #e2e8f0;
cursor: pointer;
}
.photo-file {
display: none;
}
+1 -1
View File
@@ -144,7 +144,7 @@ onMounted(() => {
</li>
</ul>
<p class="muted">
عکس فعالیت، پیوست یادداشت، لوگو، کاور فرم، تصویر پروفایل، پیوست مالی و فایل گفتگو حساب می‌شود.
عکس و صوت فعالیت، پیوست یادداشت، لوگو، کاور فرم، تصویر پروفایل، پیوست مالی و فایل گفتگو حساب می‌شود.
متن مشتری، فعالیت و فاکتور حساب نمی‌شود. فایل‌های قدیمی گفتگو که قبلاً روی سرور خارجی رفته‌اند داخل این عدد نیستند.
</p>
<label class="quota">