feat(activities): attach voice and files on activity notes
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -352,7 +352,7 @@ export class ActivitiesController {
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 2 * 1024 * 1024 },
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
}),
|
||||
)
|
||||
addImage(
|
||||
|
||||
@@ -105,6 +105,46 @@ type ActivityRow = {
|
||||
const ACTIVITY_IMAGE_MAX_BYTES = 200 * 1024;
|
||||
const ACTIVITY_IMAGE_MAX_COUNT = 8;
|
||||
const ACTIVITY_IMAGE_MIME = new Set(['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif']);
|
||||
const ACTIVITY_VOICE_MAX_BYTES = 2 * 1024 * 1024;
|
||||
const ACTIVITY_VOICE_MAX_COUNT = 5;
|
||||
const ACTIVITY_VOICE_MIME = new Set(['audio/webm', 'audio/ogg', 'audio/mp4', 'audio/mpeg', 'audio/mp3', 'audio/aac', 'audio/x-m4a', 'audio/m4a', 'audio/wav', 'audio/x-wav']);
|
||||
const ACTIVITY_FILE_MAX_BYTES = 10 * 1024 * 1024;
|
||||
const ACTIVITY_FILE_MAX_COUNT = 8;
|
||||
const ACTIVITY_FILE_MIME = new Set([
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'text/csv',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'application/zip',
|
||||
'application/x-zip-compressed',
|
||||
]);
|
||||
const ACTIVITY_EXT_MIME: Record<string, string> = {
|
||||
pdf: 'application/pdf',
|
||||
txt: 'text/plain',
|
||||
csv: 'text/csv',
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
xls: 'application/vnd.ms-excel',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
ppt: 'application/vnd.ms-powerpoint',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
zip: 'application/zip',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
webm: 'audio/webm',
|
||||
ogg: 'audio/ogg',
|
||||
mp3: 'audio/mpeg',
|
||||
m4a: 'audio/mp4',
|
||||
wav: 'audio/wav',
|
||||
};
|
||||
|
||||
const include = {
|
||||
stage: { select: { id: true, kind: true, boardId: true } },
|
||||
@@ -840,19 +880,57 @@ export class ActivitiesService {
|
||||
if (!file?.buffer?.length) {
|
||||
throw new BadRequestException('فایلی ارسال نشده است');
|
||||
}
|
||||
const mimeType = (file.mimetype || '').toLowerCase();
|
||||
if (!ACTIVITY_IMAGE_MIME.has(mimeType)) {
|
||||
throw new BadRequestException('فقط عکس میتوانید اضافه کنید.');
|
||||
const namedExt = path.extname(file.originalname || '').slice(1).toLowerCase();
|
||||
let mimeType = (file.mimetype || '').toLowerCase().split(';')[0]?.trim() || '';
|
||||
if (!mimeType || mimeType === 'application/octet-stream') {
|
||||
mimeType = ACTIVITY_EXT_MIME[namedExt] || mimeType;
|
||||
}
|
||||
if (file.size > ACTIVITY_IMAGE_MAX_BYTES) {
|
||||
throw new BadRequestException('حجم عکس باید زیر 200 کیلوبایت باشد');
|
||||
const isVoice = mimeType.startsWith('audio/') || ACTIVITY_VOICE_MIME.has(mimeType);
|
||||
const isImage = ACTIVITY_IMAGE_MIME.has(mimeType);
|
||||
const isFile = ACTIVITY_FILE_MIME.has(mimeType);
|
||||
if (!isVoice && !isImage && !isFile) {
|
||||
throw new BadRequestException('این نوع فایل مجاز نیست.');
|
||||
}
|
||||
const count = await this.prisma.activityImage.count({ where: { activityId } });
|
||||
if (count >= ACTIVITY_IMAGE_MAX_COUNT) {
|
||||
throw new BadRequestException(`حداکثر ${ACTIVITY_IMAGE_MAX_COUNT} عکس برای هر فعالیت مجاز است`);
|
||||
if (isVoice) {
|
||||
if (file.size > ACTIVITY_VOICE_MAX_BYTES) {
|
||||
throw new BadRequestException('حجم صوت باید زیر ۲ مگابایت باشد');
|
||||
}
|
||||
const voiceCount = await this.prisma.activityImage.count({
|
||||
where: { activityId, mimeType: { startsWith: 'audio/' } },
|
||||
});
|
||||
if (voiceCount >= ACTIVITY_VOICE_MAX_COUNT) {
|
||||
throw new BadRequestException(`حداکثر ${ACTIVITY_VOICE_MAX_COUNT} صوت برای هر فعالیت مجاز است`);
|
||||
}
|
||||
} else if (isImage) {
|
||||
if (file.size > ACTIVITY_IMAGE_MAX_BYTES) {
|
||||
throw new BadRequestException('حجم عکس باید زیر 200 کیلوبایت باشد');
|
||||
}
|
||||
const count = await this.prisma.activityImage.count({
|
||||
where: { activityId, mimeType: { startsWith: 'image/' } },
|
||||
});
|
||||
if (count >= ACTIVITY_IMAGE_MAX_COUNT) {
|
||||
throw new BadRequestException(`حداکثر ${ACTIVITY_IMAGE_MAX_COUNT} عکس برای هر فعالیت مجاز است`);
|
||||
}
|
||||
} else {
|
||||
if (file.size > ACTIVITY_FILE_MAX_BYTES) {
|
||||
throw new BadRequestException('حجم فایل باید زیر ۱۰ مگابایت باشد');
|
||||
}
|
||||
const fileCount = await this.prisma.activityImage.count({
|
||||
where: {
|
||||
activityId,
|
||||
NOT: {
|
||||
OR: [{ mimeType: { startsWith: 'image/' } }, { mimeType: { startsWith: 'audio/' } }],
|
||||
},
|
||||
},
|
||||
});
|
||||
if (fileCount >= ACTIVITY_FILE_MAX_COUNT) {
|
||||
throw new BadRequestException(`حداکثر ${ACTIVITY_FILE_MAX_COUNT} فایل برای هر فعالیت مجاز است`);
|
||||
}
|
||||
}
|
||||
const fileName = sanitizeActivityImageName(file.originalname);
|
||||
const ext = path.extname(fileName).slice(0, 12) || '.jpg';
|
||||
const ext =
|
||||
path.extname(fileName).slice(0, 12) ||
|
||||
(mimeType === 'audio/ogg' ? '.ogg' : mimeType === 'audio/mp4' ? '.m4a' : isVoice ? '.webm' : '.jpg');
|
||||
const imageId = randomUUID().replace(/-/g, '').slice(0, 24);
|
||||
const storageKey = path
|
||||
.join(organizationId, 'activities', activityId, `${imageId}${ext}`)
|
||||
@@ -896,7 +974,7 @@ export class ActivitiesService {
|
||||
const row = await this.prisma.activityImage.findFirst({
|
||||
where: { id: imageId, activityId, organizationId },
|
||||
});
|
||||
if (!row) throw new NotFoundException('عکس پیدا نشد');
|
||||
if (!row) throw new NotFoundException('فایل پیدا نشد');
|
||||
await this.files.remove(row.storageKey);
|
||||
await this.prisma.activityImage.delete({ where: { id: row.id } });
|
||||
await this.storage.addLocal(organizationId, -row.sizeBytes);
|
||||
@@ -912,7 +990,7 @@ export class ActivitiesService {
|
||||
const row = await this.prisma.activityImage.findFirst({
|
||||
where: { id: imageId, activityId, organizationId },
|
||||
});
|
||||
if (!row) throw new NotFoundException('عکس پیدا نشد');
|
||||
if (!row) throw new NotFoundException('فایل پیدا نشد');
|
||||
return {
|
||||
stream: this.files.openReadStream(row.storageKey),
|
||||
fileName: row.fileName,
|
||||
|
||||
@@ -68,6 +68,7 @@ defineProps<{
|
||||
| 'pulse'
|
||||
| 'sliders'
|
||||
| 'camera'
|
||||
| 'clip'
|
||||
| 'download'
|
||||
| 'upload'
|
||||
| 'arrow-right'
|
||||
@@ -423,6 +424,9 @@ defineProps<{
|
||||
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3Z" />
|
||||
<circle cx="12" cy="13" r="3" />
|
||||
</template>
|
||||
<template v-else-if="name === 'clip'">
|
||||
<path d="M21.4 11.6 12.7 20.3a5 5 0 0 1-7.1-7.1l8.5-8.4a3.5 3.5 0 0 1 5 5L10.6 18.2a2 2 0 1 1-2.8-2.8l7.1-7.1" />
|
||||
</template>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
formatClockFa,
|
||||
formatJalaliDay,
|
||||
} from '@/lib/jalali';
|
||||
import { FormFileTooLargeError, prepareFormFieldFile } from '@/lib/form-file-compress';
|
||||
import { prepareActivityAttachment } from '@/lib/activity-attachment';
|
||||
import { startVoiceRecorder, type VoiceRecorder } from '@/lib/voice-note';
|
||||
import { appendSpokenText } from '@/lib/spoken-text';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
|
||||
@@ -29,18 +30,26 @@ const typeIcon: Record<ActivityType, 'phone' | 'users' | 'check' | 'chat'> = {
|
||||
note: 'chat',
|
||||
};
|
||||
|
||||
const PHOTO_TOO_LARGE = 'حجم عکس باید زیر 200 کیلوبایت باشد';
|
||||
const PHOTO_LIMIT = 8;
|
||||
const VOICE_LIMIT = 5;
|
||||
const FILE_LIMIT = 8;
|
||||
type PendingPhoto = { localId: string; name: string; dataUrl: string; file: File };
|
||||
type PendingVoice = { localId: string; name: string; url: string; file: File };
|
||||
type PendingFile = { localId: string; name: string; file: File };
|
||||
|
||||
const comment = ref('');
|
||||
const textareaRef = ref<HTMLTextAreaElement | null>(null);
|
||||
const pendingPhotos = ref<PendingPhoto[]>([]);
|
||||
const pendingVoices = ref<PendingVoice[]>([]);
|
||||
const pendingFiles = ref<PendingFile[]>([]);
|
||||
const uploadedCount = ref(0);
|
||||
const photoBusy = ref(false);
|
||||
const uploading = ref(false);
|
||||
const photoError = ref('');
|
||||
const photoInput = ref<HTMLInputElement | null>(null);
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const voiceNoteOn = ref(false);
|
||||
const voiceNoteSeconds = ref(0);
|
||||
let voiceNote: VoiceRecorder | null = null;
|
||||
const orgName = ref('سازمان');
|
||||
const users = ref<OrgUserDto[]>([]);
|
||||
const mentionOpen = ref(false);
|
||||
@@ -72,17 +81,29 @@ const nowLabel = computed(
|
||||
() => `${formatJalaliDay(now.value)} ساعت ${formatClockFa(now.value)}`,
|
||||
);
|
||||
|
||||
const photoSlotsUsed = computed(
|
||||
() => (props.activity.images?.length ?? 0) + uploadedCount.value + pendingPhotos.value.length,
|
||||
);
|
||||
function imageCount(kind: 'image' | 'audio' | 'file') {
|
||||
return (
|
||||
props.activity.images?.filter((image) => {
|
||||
const mime = image.mimeType.toLowerCase();
|
||||
if (kind === 'audio') return mime.startsWith('audio/');
|
||||
if (kind === 'image') return mime.startsWith('image/');
|
||||
return !mime.startsWith('audio/') && !mime.startsWith('image/');
|
||||
}).length ?? 0
|
||||
);
|
||||
}
|
||||
|
||||
const canSubmit = computed(
|
||||
() =>
|
||||
(comment.value.trim().length > 0 || pendingPhotos.value.length > 0 || uploadedCount.value > 0) &&
|
||||
(comment.value.trim().length > 0 ||
|
||||
pendingPhotos.value.length > 0 ||
|
||||
pendingVoices.value.length > 0 ||
|
||||
pendingFiles.value.length > 0 ||
|
||||
uploadedCount.value > 0) &&
|
||||
!props.saving &&
|
||||
!voiceBusy.value &&
|
||||
!photoBusy.value &&
|
||||
!uploading.value,
|
||||
!uploading.value &&
|
||||
!voiceNoteOn.value,
|
||||
);
|
||||
|
||||
function onVoiceTranscript(text: string) {
|
||||
@@ -105,36 +126,45 @@ function apiMessage(error: unknown, fallback: string) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function onPhotosPicked(event: Event) {
|
||||
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 = '';
|
||||
for (const file of files) {
|
||||
if (photoSlotsUsed.value >= 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,
|
||||
},
|
||||
];
|
||||
const prepared = await prepareActivityAttachment(file);
|
||||
if (prepared.kind === 'image') {
|
||||
if (imageCount('image') + pendingPhotos.value.length >= PHOTO_LIMIT) {
|
||||
photoError.value = `حداکثر ${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 (imageCount('audio') + pendingVoices.value.length >= VOICE_LIMIT) {
|
||||
URL.revokeObjectURL(prepared.previewUrl);
|
||||
photoError.value = `حداکثر ${VOICE_LIMIT} صوت برای هر فعالیت.`;
|
||||
continue;
|
||||
}
|
||||
pendingVoices.value = [
|
||||
...pendingVoices.value,
|
||||
{ localId: crypto.randomUUID(), name: prepared.name, url: prepared.previewUrl, file: prepared.file },
|
||||
];
|
||||
} else if (imageCount('file') + pendingFiles.value.length >= FILE_LIMIT) {
|
||||
photoError.value = `حداکثر ${FILE_LIMIT} فایل برای هر فعالیت.`;
|
||||
} else {
|
||||
pendingFiles.value = [
|
||||
...pendingFiles.value,
|
||||
{ localId: crypto.randomUUID(), name: prepared.name, file: prepared.file },
|
||||
];
|
||||
}
|
||||
} catch (error) {
|
||||
photoError.value =
|
||||
error instanceof FormFileTooLargeError ? PHOTO_TOO_LARGE : 'فشردهسازی عکس ناموفق بود.';
|
||||
photoError.value = error instanceof Error ? error.message : 'افزودن فایل ناموفق بود.';
|
||||
} finally {
|
||||
photoBusy.value = false;
|
||||
}
|
||||
@@ -143,18 +173,86 @@ async function onPhotosPicked(event: Event) {
|
||||
|
||||
function removePendingPhoto(localId: string) {
|
||||
pendingPhotos.value = pendingPhotos.value.filter((photo) => photo.localId !== localId);
|
||||
if (photoError.value === PHOTO_TOO_LARGE) photoError.value = '';
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function removePendingFile(localId: string) {
|
||||
pendingFiles.value = pendingFiles.value.filter((item) => item.localId !== localId);
|
||||
}
|
||||
|
||||
function voiceClock(total: number) {
|
||||
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function addVoiceNote(file: File) {
|
||||
if (imageCount('audio') + pendingVoices.value.length >= VOICE_LIMIT) {
|
||||
photoError.value = `حداکثر ${VOICE_LIMIT} صوت برای هر فعالیت.`;
|
||||
return;
|
||||
}
|
||||
pendingVoices.value = [
|
||||
...pendingVoices.value,
|
||||
{ localId: crypto.randomUUID(), name: file.name, url: URL.createObjectURL(file), file },
|
||||
];
|
||||
}
|
||||
|
||||
async function toggleVoiceNote() {
|
||||
photoError.value = '';
|
||||
if (voiceNote) {
|
||||
const handle = voiceNote;
|
||||
voiceNote = null;
|
||||
voiceNoteOn.value = false;
|
||||
try {
|
||||
addVoiceNote(await handle.stop());
|
||||
} catch (error) {
|
||||
photoError.value = error instanceof Error ? error.message : 'ضبط صوت ناموفق بود.';
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
voiceNoteSeconds.value = 0;
|
||||
voiceNote = await startVoiceRecorder({
|
||||
onTick: (seconds) => {
|
||||
voiceNoteSeconds.value = seconds;
|
||||
},
|
||||
onAutoStop: (file) => {
|
||||
voiceNote = null;
|
||||
voiceNoteOn.value = false;
|
||||
addVoiceNote(file);
|
||||
},
|
||||
});
|
||||
voiceNoteOn.value = true;
|
||||
} catch (error) {
|
||||
photoError.value = error instanceof Error ? error.message : 'ضبط صوت ناموفق بود.';
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadQueue(items: Array<{ localId: string; name: string; file: File }>, drop: (localId: string) => void) {
|
||||
for (const item of items) {
|
||||
const form = new FormData();
|
||||
form.append('file', item.file, item.name);
|
||||
await api.post(`/activities/${props.activity.id}/images`, form);
|
||||
drop(item.localId);
|
||||
uploadedCount.value += 1;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadPendingPhotos() {
|
||||
const queue = [...pendingPhotos.value];
|
||||
for (const photo of queue) {
|
||||
const form = new FormData();
|
||||
form.append('file', photo.file, photo.name);
|
||||
await api.post(`/activities/${props.activity.id}/images`, form);
|
||||
pendingPhotos.value = pendingPhotos.value.filter((item) => item.localId !== photo.localId);
|
||||
uploadedCount.value += 1;
|
||||
}
|
||||
await uploadQueue([...pendingPhotos.value], (localId) => {
|
||||
pendingPhotos.value = pendingPhotos.value.filter((item) => item.localId !== localId);
|
||||
});
|
||||
await uploadQueue([...pendingVoices.value], (localId) => {
|
||||
const voice = pendingVoices.value.find((item) => item.localId === localId);
|
||||
if (voice) URL.revokeObjectURL(voice.url);
|
||||
pendingVoices.value = pendingVoices.value.filter((item) => item.localId !== localId);
|
||||
});
|
||||
await uploadQueue([...pendingFiles.value], (localId) => {
|
||||
pendingFiles.value = pendingFiles.value.filter((item) => item.localId !== localId);
|
||||
});
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
@@ -162,14 +260,18 @@ async function submit() {
|
||||
if (!canSubmit.value) return;
|
||||
photoError.value = '';
|
||||
uploading.value = true;
|
||||
const hadPhoto = pendingPhotos.value.length > 0 || uploadedCount.value > 0;
|
||||
const hadPhoto =
|
||||
pendingPhotos.value.length > 0 ||
|
||||
pendingVoices.value.length > 0 ||
|
||||
pendingFiles.value.length > 0 ||
|
||||
uploadedCount.value > 0;
|
||||
try {
|
||||
await uploadPendingPhotos();
|
||||
await props.save({ text, photoOnly: hadPhoto && !text });
|
||||
} catch (error) {
|
||||
photoError.value =
|
||||
pendingPhotos.value.length > 0
|
||||
? apiMessage(error, 'افزودن عکس ناموفق بود.')
|
||||
? apiMessage(error, 'افزودن فایل ناموفق بود.')
|
||||
: apiMessage(error, 'ثبت نظر ناموفق بود.');
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
@@ -243,6 +345,8 @@ onMounted(async () => {
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
voiceNote?.cancel();
|
||||
for (const voice of pendingVoices.value) URL.revokeObjectURL(voice.url);
|
||||
document.body.style.overflow = '';
|
||||
window.removeEventListener('keydown', onKeydown);
|
||||
clearInterval(clockTimer);
|
||||
@@ -308,26 +412,6 @@ onBeforeUnmount(() => {
|
||||
rows="6"
|
||||
placeholder="متن خود را بنویسید..."
|
||||
/>
|
||||
<div class="photo-row">
|
||||
<button
|
||||
class="photo-add"
|
||||
type="button"
|
||||
:disabled="photoBusy || uploading || recording || photoSlotsUsed >= PHOTO_LIMIT"
|
||||
@click="photoInput?.click()"
|
||||
>
|
||||
<UiIcon name="camera" :size="16" />
|
||||
{{ photoBusy ? 'در حال کمحجم کردن عکس…' : 'افزودن عکس' }}
|
||||
</button>
|
||||
<input
|
||||
ref="photoInput"
|
||||
class="photo-file"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
@change="onPhotosPicked"
|
||||
/>
|
||||
<small>عکس خودکار کمحجم میشود تا زیر ۲۰۰ کیلوبایت بماند.</small>
|
||||
</div>
|
||||
<p v-if="photoError" class="photo-err">{{ photoError }}</p>
|
||||
<div v-if="pendingPhotos.length" class="photo-grid">
|
||||
<figure v-for="photo in pendingPhotos" :key="photo.localId">
|
||||
@@ -337,13 +421,31 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
</figure>
|
||||
</div>
|
||||
<div v-if="pendingVoices.length" class="voice-list">
|
||||
<div v-for="voice in pendingVoices" :key="voice.localId" class="voice-item">
|
||||
<audio :src="voice.url" controls preload="metadata" />
|
||||
<button type="button" aria-label="حذف صوت" :disabled="uploading" @click="removePendingVoice(voice.localId)">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul v-if="pendingFiles.length" class="file-list">
|
||||
<li v-for="file in pendingFiles" :key="file.localId">
|
||||
<UiIcon name="document" :size="15" />
|
||||
<span>{{ file.name }}</span>
|
||||
<button type="button" aria-label="حذف فایل" :disabled="uploading" @click="removePendingFile(file.localId)">
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tools">
|
||||
<div class="tool-cluster">
|
||||
<div class="tool-wrap">
|
||||
<button
|
||||
class="tool"
|
||||
type="button"
|
||||
aria-label="منشن"
|
||||
:disabled="recording"
|
||||
:disabled="recording || voiceNoteOn"
|
||||
@click="mentionOpen = !mentionOpen"
|
||||
>
|
||||
@
|
||||
@@ -357,12 +459,36 @@ onBeforeUnmount(() => {
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button
|
||||
class="tool"
|
||||
type="button"
|
||||
aria-label="پیوست"
|
||||
title="پیوست"
|
||||
:disabled="photoBusy || uploading || recording || voiceNoteOn"
|
||||
@click="fileInput?.click()"
|
||||
>
|
||||
<UiIcon name="clip" :size="16" />
|
||||
</button>
|
||||
<input ref="fileInput" class="photo-file" type="file" multiple @change="onFilesPicked" />
|
||||
<button
|
||||
class="tool"
|
||||
type="button"
|
||||
:class="{ 'voice-on': voiceNoteOn }"
|
||||
:aria-label="voiceNoteOn ? 'توقف ضبط پیام صوتی' : 'ضبط پیام صوتی'"
|
||||
:title="voiceNoteOn ? 'توقف ضبط پیام صوتی' : 'ضبط پیام صوتی'"
|
||||
:disabled="photoBusy || uploading || recording"
|
||||
@click="toggleVoiceNote"
|
||||
>
|
||||
<UiIcon name="mic" :size="16" />
|
||||
</button>
|
||||
<span v-if="voiceNoteOn" class="rec-time">{{ voiceClock(voiceNoteSeconds) }}</span>
|
||||
</div>
|
||||
<div class="tool-wrap">
|
||||
<p v-if="voiceTip && !recording" class="tip">تایپ صوتی را امتحان کنید! 🎙️✨</p>
|
||||
<VoiceToTextButton
|
||||
variant="toolbar"
|
||||
hide-error
|
||||
:disabled="saving"
|
||||
:disabled="saving || voiceNoteOn"
|
||||
@transcript="onVoiceTranscript"
|
||||
@recording="recording = $event"
|
||||
@busy="onVoiceBusy"
|
||||
@@ -665,6 +791,73 @@ onBeforeUnmount(() => {
|
||||
padding: 0.35rem 0.5rem 0.5rem;
|
||||
}
|
||||
|
||||
.tool-cluster {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.tool.voice-on {
|
||||
border-color: #fecaca;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.rec-time {
|
||||
color: #b91c1c;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.voice-list {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.voice-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.voice-item audio {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 2.1rem;
|
||||
}
|
||||
|
||||
.voice-item button,
|
||||
.file-list button {
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: #e2e8f0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 0.5rem;
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.file-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-list span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tool-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { FormFileTooLargeError, prepareFormFieldFile } from '@/lib/form-file-compress';
|
||||
import { compressVoiceFile } from '@/lib/voice-note';
|
||||
|
||||
export const ACTIVITY_FILE_MAX_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
const FILE_MIME_BY_EXT: Record<string, string> = {
|
||||
pdf: 'application/pdf',
|
||||
txt: 'text/plain',
|
||||
csv: 'text/csv',
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
xls: 'application/vnd.ms-excel',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
ppt: 'application/vnd.ms-powerpoint',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
zip: 'application/zip',
|
||||
};
|
||||
|
||||
const IMAGE_EXT = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp']);
|
||||
const AUDIO_EXT = new Set(['webm', 'ogg', 'mp3', 'm4a', 'aac', 'wav', 'mpeg']);
|
||||
|
||||
export type PreparedAttachment = {
|
||||
kind: 'image' | 'audio' | 'file';
|
||||
name: string;
|
||||
file: File;
|
||||
previewUrl: string;
|
||||
};
|
||||
|
||||
function extensionOf(name: string): string {
|
||||
return name.split('.').pop()?.toLowerCase().replace(/[^a-z0-9]/g, '') ?? '';
|
||||
}
|
||||
|
||||
function kindOf(file: File): 'image' | 'audio' | 'file' {
|
||||
const type = file.type.toLowerCase().split(';')[0] ?? '';
|
||||
const ext = extensionOf(file.name);
|
||||
if (type.startsWith('image/') || IMAGE_EXT.has(ext)) return 'image';
|
||||
if (type.startsWith('audio/') || AUDIO_EXT.has(ext)) return 'audio';
|
||||
return 'file';
|
||||
}
|
||||
|
||||
export async function prepareActivityAttachment(file: File): Promise<PreparedAttachment> {
|
||||
const kind = kindOf(file);
|
||||
if (kind === 'image') {
|
||||
try {
|
||||
const prepared = await prepareFormFieldFile(file);
|
||||
return { kind, name: prepared.name, file: prepared.file, previewUrl: prepared.dataUrl };
|
||||
} catch (error) {
|
||||
if (error instanceof FormFileTooLargeError) {
|
||||
throw new Error('حجم عکس باید زیر ۲۰۰ کیلوبایت باشد');
|
||||
}
|
||||
throw new Error('فشردهسازی عکس ناموفق بود.');
|
||||
}
|
||||
}
|
||||
if (kind === 'audio') {
|
||||
const compressed = await compressVoiceFile(file);
|
||||
return {
|
||||
kind,
|
||||
name: compressed.name,
|
||||
file: compressed,
|
||||
previewUrl: URL.createObjectURL(compressed),
|
||||
};
|
||||
}
|
||||
const ext = extensionOf(file.name);
|
||||
const mime = FILE_MIME_BY_EXT[ext];
|
||||
if (!mime) throw new Error('این نوع فایل مجاز نیست.');
|
||||
if (file.size > ACTIVITY_FILE_MAX_BYTES) throw new Error('حجم فایل باید زیر ۱۰ مگابایت باشد');
|
||||
const named = file.type === mime ? file : new File([file], file.name, { type: mime });
|
||||
return { kind: 'file', name: named.name, file: named, previewUrl: '' };
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/** پیام صوتی به سبک بله و تلگرام: Opus تککاناله، حدود ۳۲ کیلوبیت بر ثانیه. */
|
||||
export const VOICE_BITRATE = 32_000;
|
||||
export const VOICE_MAX_SECONDS = 5 * 60;
|
||||
export const VOICE_MAX_BYTES = Math.floor(1.8 * 1024 * 1024);
|
||||
|
||||
const MIME_CANDIDATES = [
|
||||
'audio/webm;codecs=opus',
|
||||
'audio/ogg;codecs=opus',
|
||||
'audio/webm',
|
||||
'audio/mp4',
|
||||
];
|
||||
|
||||
export function voiceRecorderMime(): string {
|
||||
if (typeof MediaRecorder === 'undefined') return '';
|
||||
return MIME_CANDIDATES.find((mime) => MediaRecorder.isTypeSupported(mime)) ?? '';
|
||||
}
|
||||
|
||||
function extensionFor(mime: string): string {
|
||||
if (mime.includes('mp4')) return 'm4a';
|
||||
if (mime.includes('ogg')) return 'ogg';
|
||||
return 'webm';
|
||||
}
|
||||
|
||||
function fileFromBlob(blob: Blob, mime: string): File {
|
||||
const type = mime.split(';')[0] || blob.type || 'audio/webm';
|
||||
return new File([blob], `voice.${extensionFor(mime)}`, { type });
|
||||
}
|
||||
|
||||
export type VoiceRecorder = {
|
||||
stop: () => Promise<File>;
|
||||
cancel: () => void;
|
||||
};
|
||||
|
||||
export async function startVoiceRecorder(handlers: {
|
||||
onTick: (seconds: number) => void;
|
||||
onAutoStop: (file: File) => void;
|
||||
}): Promise<VoiceRecorder> {
|
||||
const mime = voiceRecorderMime();
|
||||
if (!mime || !navigator.mediaDevices?.getUserMedia) {
|
||||
throw new Error('این مرورگر ضبط صوت را پشتیبانی نمیکند.');
|
||||
}
|
||||
let stream: MediaStream;
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
channelCount: { ideal: 1 },
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
throw new Error('دسترسی به میکروفون داده نشد.');
|
||||
}
|
||||
|
||||
const rec = new MediaRecorder(stream, { mimeType: mime, audioBitsPerSecond: VOICE_BITRATE });
|
||||
const chunks: Blob[] = [];
|
||||
rec.ondataavailable = (event) => {
|
||||
if (event.data.size) chunks.push(event.data);
|
||||
};
|
||||
const started = Date.now();
|
||||
let settled = false;
|
||||
let timer = 0;
|
||||
let auto = 0;
|
||||
|
||||
const stopped = new Promise<File>((resolve, reject) => {
|
||||
rec.onstop = () => {
|
||||
const file = fileFromBlob(new Blob(chunks, { type: mime.split(';')[0] }), mime);
|
||||
if (!file.size) {
|
||||
reject(new Error('صدایی ضبط نشد.'));
|
||||
return;
|
||||
}
|
||||
if (file.size > VOICE_MAX_BYTES) {
|
||||
reject(new Error('حجم صوت بعد از فشردهسازی هنوز زیاد است.'));
|
||||
return;
|
||||
}
|
||||
resolve(file);
|
||||
};
|
||||
rec.onerror = () => reject(new Error('ضبط صوت ناموفق بود.'));
|
||||
});
|
||||
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
window.clearInterval(timer);
|
||||
window.clearTimeout(auto);
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
if (rec.state !== 'inactive') rec.stop();
|
||||
};
|
||||
|
||||
timer = window.setInterval(() => {
|
||||
handlers.onTick(Math.min(VOICE_MAX_SECONDS, Math.round((Date.now() - started) / 1000)));
|
||||
}, 250);
|
||||
auto = window.setTimeout(() => {
|
||||
finish();
|
||||
void stopped.then(handlers.onAutoStop).catch(() => undefined);
|
||||
}, VOICE_MAX_SECONDS * 1000);
|
||||
rec.start(250);
|
||||
|
||||
return {
|
||||
stop: () => {
|
||||
finish();
|
||||
return stopped;
|
||||
},
|
||||
cancel: () => {
|
||||
chunks.length = 0;
|
||||
finish();
|
||||
void stopped.catch(() => undefined);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function compressVoiceFile(file: File): Promise<File> {
|
||||
const mime = voiceRecorderMime();
|
||||
if (!mime) throw new Error('این مرورگر فشردهسازی صوت را پشتیبانی نمیکند.');
|
||||
const ctx = new AudioContext();
|
||||
let audio: AudioBuffer;
|
||||
try {
|
||||
audio = await ctx.decodeAudioData(await file.arrayBuffer());
|
||||
} catch {
|
||||
await ctx.close();
|
||||
throw new Error('این فایل صوتی قابل خواندن نیست.');
|
||||
}
|
||||
if (audio.duration > VOICE_MAX_SECONDS + 0.5) {
|
||||
await ctx.close();
|
||||
throw new Error('صوت حداکثر ۵ دقیقه میتواند باشد.');
|
||||
}
|
||||
|
||||
const mono = ctx.createBuffer(1, audio.length, audio.sampleRate);
|
||||
const out = mono.getChannelData(0);
|
||||
for (let i = 0; i < audio.length; i += 1) {
|
||||
let sum = 0;
|
||||
for (let channel = 0; channel < audio.numberOfChannels; channel += 1) {
|
||||
sum += audio.getChannelData(channel)[i] ?? 0;
|
||||
}
|
||||
out[i] = sum / audio.numberOfChannels;
|
||||
}
|
||||
|
||||
const dest = ctx.createMediaStreamDestination();
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = mono;
|
||||
source.connect(dest);
|
||||
const rec = new MediaRecorder(dest.stream, { mimeType: mime, audioBitsPerSecond: VOICE_BITRATE });
|
||||
const chunks: Blob[] = [];
|
||||
rec.ondataavailable = (event) => {
|
||||
if (event.data.size) chunks.push(event.data);
|
||||
};
|
||||
const encoded = new Promise<Blob>((resolve, reject) => {
|
||||
rec.onstop = () => resolve(new Blob(chunks, { type: mime.split(';')[0] }));
|
||||
rec.onerror = () => reject(new Error('فشردهسازی صوت ناموفق بود.'));
|
||||
});
|
||||
rec.start(250);
|
||||
source.start();
|
||||
await new Promise<void>((resolve) => {
|
||||
source.onended = () => resolve();
|
||||
});
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 180));
|
||||
if (rec.state !== 'inactive') rec.stop();
|
||||
const blob = await encoded;
|
||||
source.disconnect();
|
||||
await ctx.close();
|
||||
const result = fileFromBlob(blob, mime);
|
||||
if (!result.size) throw new Error('فشردهسازی صوت ناموفق بود.');
|
||||
if (result.size > VOICE_MAX_BYTES) throw new Error('حجم صوت بعد از فشردهسازی هنوز زیاد است.');
|
||||
return result;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ onMounted(() => {
|
||||
</li>
|
||||
</ul>
|
||||
<p class="muted">
|
||||
عکس فعالیت، پیوست یادداشت، لوگو، کاور فرم، تصویر پروفایل، پیوست مالی و فایل گفتگو حساب میشود.
|
||||
عکس و صوت فعالیت، پیوست یادداشت، لوگو، کاور فرم، تصویر پروفایل، پیوست مالی و فایل گفتگو حساب میشود.
|
||||
متن مشتری، فعالیت و فاکتور حساب نمیشود. فایلهای قدیمی گفتگو که قبلاً روی سرور خارجی رفتهاند داخل این عدد نیستند.
|
||||
</p>
|
||||
<label class="quota">
|
||||
|
||||
Reference in New Issue
Block a user