Add file & video & voice in chat Crm
This commit is contained in:
@@ -12,11 +12,20 @@ import {
|
||||
onRoomLiveEvent,
|
||||
useOrgChat,
|
||||
type ChatDelivery,
|
||||
type ChatMessage,
|
||||
type RoomLiveEvent,
|
||||
} from '@/lib/useOrgChat';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import UserAvatar from '@/components/UserAvatar.vue';
|
||||
|
||||
type PendingAttach = {
|
||||
id: string;
|
||||
file: File;
|
||||
kind: 'image' | 'voice' | 'video' | 'file';
|
||||
previewUrl: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const access = ref<ConversationAccessDto | null>(null);
|
||||
const rooms = ref<ConversationRoomDto[]>([]);
|
||||
const peers = ref<ConversationPeerDto[]>([]);
|
||||
@@ -30,6 +39,12 @@ const groupName = ref('');
|
||||
const groupMemberIds = ref<string[]>([]);
|
||||
const dmPeerId = ref('');
|
||||
const chatUserId = ref('');
|
||||
const pendingAttaches = ref<PendingAttach[]>([]);
|
||||
const sending = ref(false);
|
||||
const recording = ref(false);
|
||||
const recordSecs = ref(0);
|
||||
const lightboxUrl = ref('');
|
||||
const fileInputEl = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const {
|
||||
messages,
|
||||
@@ -37,6 +52,7 @@ const {
|
||||
status,
|
||||
loadMessages,
|
||||
sendText,
|
||||
sendFile,
|
||||
sendRead,
|
||||
ensureConnected,
|
||||
setConversationsPageOpen,
|
||||
@@ -45,6 +61,10 @@ const {
|
||||
} = useOrgChat();
|
||||
const msgStreamEl = ref<HTMLElement | null>(null);
|
||||
let stopRoomLive: (() => boolean) | undefined;
|
||||
let mediaRecorder: MediaRecorder | null = null;
|
||||
let recordChunks: Blob[] = [];
|
||||
let recordTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let recordStream: MediaStream | null = null;
|
||||
|
||||
const canCreateGroup = computed(() => access.value?.canCreateGroup ?? false);
|
||||
|
||||
@@ -84,6 +104,129 @@ function deliveryLabel(delivery?: ChatDelivery) {
|
||||
return '…';
|
||||
}
|
||||
|
||||
function classifyPending(file: File): PendingAttach['kind'] {
|
||||
const mime = (file.type || '').toLowerCase();
|
||||
if (mime.startsWith('image/')) return 'image';
|
||||
if (mime.startsWith('audio/')) return 'voice';
|
||||
if (mime.startsWith('video/')) return 'video';
|
||||
return 'file';
|
||||
}
|
||||
|
||||
function clearPendingAttaches() {
|
||||
for (const item of pendingAttaches.value) {
|
||||
if (item.previewUrl.startsWith('blob:')) {
|
||||
try {
|
||||
URL.revokeObjectURL(item.previewUrl);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
pendingAttaches.value = [];
|
||||
}
|
||||
|
||||
function removePending(id: string) {
|
||||
const item = pendingAttaches.value.find((a) => a.id === id);
|
||||
if (item?.previewUrl.startsWith('blob:')) {
|
||||
try {
|
||||
URL.revokeObjectURL(item.previewUrl);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
pendingAttaches.value = pendingAttaches.value.filter((a) => a.id !== id);
|
||||
}
|
||||
|
||||
function addFiles(files: FileList | File[]) {
|
||||
const list = Array.from(files);
|
||||
for (const file of list) {
|
||||
pendingAttaches.value = [
|
||||
...pendingAttaches.value,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
kind: classifyPending(file),
|
||||
previewUrl: URL.createObjectURL(file),
|
||||
name: file.name,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
function onFilePicked(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files?.length) addFiles(input.files);
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function mediaSrc(msg: ChatMessage) {
|
||||
return msg.localPreviewUrl || msg.mediaUrl || '';
|
||||
}
|
||||
|
||||
function stopRecordTimer() {
|
||||
if (recordTimer) {
|
||||
clearInterval(recordTimer);
|
||||
recordTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function stopMicTracks() {
|
||||
recordStream?.getTracks().forEach((t) => t.stop());
|
||||
recordStream = null;
|
||||
}
|
||||
|
||||
async function toggleVoiceRecord() {
|
||||
if (recording.value) {
|
||||
mediaRecorder?.stop();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
recordStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
recordChunks = [];
|
||||
const mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
||||
? 'audio/webm;codecs=opus'
|
||||
: MediaRecorder.isTypeSupported('audio/webm')
|
||||
? 'audio/webm'
|
||||
: '';
|
||||
mediaRecorder = mime
|
||||
? new MediaRecorder(recordStream, { mimeType: mime })
|
||||
: new MediaRecorder(recordStream);
|
||||
mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data.size) recordChunks.push(e.data);
|
||||
};
|
||||
mediaRecorder.onstop = () => {
|
||||
stopRecordTimer();
|
||||
recording.value = false;
|
||||
const type = mediaRecorder?.mimeType || 'audio/webm';
|
||||
stopMicTracks();
|
||||
mediaRecorder = null;
|
||||
if (!recordChunks.length) return;
|
||||
const blob = new Blob(recordChunks, { type });
|
||||
recordChunks = [];
|
||||
const ext = type.includes('ogg') ? 'ogg' : type.includes('mp4') ? 'm4a' : 'webm';
|
||||
const file = new File([blob], `voice-${Date.now()}.${ext}`, { type });
|
||||
addFiles([file]);
|
||||
recordSecs.value = 0;
|
||||
};
|
||||
mediaRecorder.start();
|
||||
recording.value = true;
|
||||
recordSecs.value = 0;
|
||||
recordTimer = setInterval(() => {
|
||||
recordSecs.value += 1;
|
||||
}, 1000);
|
||||
} catch {
|
||||
error.value = 'دسترسی به میکروفون ممکن نشد.';
|
||||
stopMicTracks();
|
||||
recording.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function recordLabel() {
|
||||
const m = Math.floor(recordSecs.value / 60);
|
||||
const s = recordSecs.value % 60;
|
||||
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function applyRoomLive(event: RoomLiveEvent) {
|
||||
const idx = rooms.value.findIndex((r) => r.externalRoomId === event.roomId);
|
||||
if (idx < 0) {
|
||||
@@ -186,12 +329,39 @@ async function openRoom(room: ConversationRoomDto) {
|
||||
}
|
||||
|
||||
async function submitMessage() {
|
||||
if (!activeRoom.value || !draft.value.trim()) return;
|
||||
if (!activeRoom.value) return;
|
||||
if (sending.value || recording.value) return;
|
||||
const text = draft.value.trim();
|
||||
const attaches = [...pendingAttaches.value];
|
||||
if (!text && !attaches.length) return;
|
||||
|
||||
sending.value = true;
|
||||
error.value = '';
|
||||
const roomId = activeRoom.value.externalRoomId;
|
||||
draft.value = '';
|
||||
pendingAttaches.value = [];
|
||||
|
||||
try {
|
||||
await sendText(activeRoom.value.externalRoomId, draft.value);
|
||||
draft.value = '';
|
||||
} catch {
|
||||
error.value = 'ارسال پیام ناموفق بود.';
|
||||
if (text) {
|
||||
await sendText(roomId, text);
|
||||
}
|
||||
for (const item of attaches) {
|
||||
try {
|
||||
await sendFile(roomId, item.file);
|
||||
} finally {
|
||||
if (item.previewUrl.startsWith('blob:')) {
|
||||
try {
|
||||
URL.revokeObjectURL(item.previewUrl);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'ارسال پیام ناموفق بود.';
|
||||
} finally {
|
||||
sending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,6 +411,10 @@ function toggleGroupMember(id: string) {
|
||||
|
||||
watch(activeRoom, () => {
|
||||
draft.value = '';
|
||||
clearPendingAttaches();
|
||||
if (recording.value) {
|
||||
mediaRecorder?.stop();
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
@@ -264,6 +438,10 @@ onUnmounted(() => {
|
||||
setConversationsPageOpen(false);
|
||||
stopRoomLive?.();
|
||||
clearActiveRoom();
|
||||
clearPendingAttaches();
|
||||
if (recording.value) mediaRecorder?.stop();
|
||||
stopRecordTimer();
|
||||
stopMicTracks();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -342,8 +520,48 @@ onUnmounted(() => {
|
||||
class="msg-row"
|
||||
:class="{ mine: msg.senderId === chatUserId || msg.optimistic }"
|
||||
>
|
||||
<div class="bubble">
|
||||
<p>{{ msg.content }}</p>
|
||||
<div class="bubble" :class="{ media: msg.type !== 'text' }">
|
||||
<template v-if="msg.type === 'image'">
|
||||
<button
|
||||
type="button"
|
||||
class="media-image"
|
||||
:disabled="!mediaSrc(msg)"
|
||||
@click="lightboxUrl = mediaSrc(msg)"
|
||||
>
|
||||
<img :src="mediaSrc(msg)" :alt="msg.fileName || 'تصویر'" />
|
||||
<span v-if="msg.uploadProgress != null && msg.uploadProgress < 100" class="upload-veil">
|
||||
{{ toFaDigits(msg.uploadProgress) }}٪
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
<template v-else-if="msg.type === 'voice'">
|
||||
<audio class="media-audio" :src="mediaSrc(msg)" controls preload="metadata" />
|
||||
<span v-if="msg.uploadProgress != null && msg.uploadProgress < 100" class="upload-line">
|
||||
آپلود {{ toFaDigits(msg.uploadProgress) }}٪
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="msg.type === 'video'">
|
||||
<video class="media-video" :src="mediaSrc(msg)" controls preload="metadata" />
|
||||
<span v-if="msg.uploadProgress != null && msg.uploadProgress < 100" class="upload-line">
|
||||
آپلود {{ toFaDigits(msg.uploadProgress) }}٪
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="msg.type === 'file'">
|
||||
<a
|
||||
class="media-file"
|
||||
:href="mediaSrc(msg)"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
:download="msg.fileName || true"
|
||||
>
|
||||
<UiIcon name="document" :size="18" />
|
||||
<span>{{ msg.fileName || 'دانلود فایل' }}</span>
|
||||
</a>
|
||||
<span v-if="msg.uploadProgress != null && msg.uploadProgress < 100" class="upload-line">
|
||||
آپلود {{ toFaDigits(msg.uploadProgress) }}٪
|
||||
</span>
|
||||
</template>
|
||||
<p v-else>{{ msg.content }}</p>
|
||||
<div class="bubble-meta">
|
||||
<time>{{ formatListTime(msg.createdAt) }}</time>
|
||||
<span
|
||||
@@ -364,10 +582,66 @@ onUnmounted(() => {
|
||||
</ul>
|
||||
|
||||
<form class="composer" @submit.prevent="submitMessage">
|
||||
<input v-model="draft" type="text" placeholder="پیام خود را بنویسید…" autocomplete="off" />
|
||||
<button type="submit" :disabled="!draft.trim()">
|
||||
<UiIcon name="send" :size="18" />
|
||||
</button>
|
||||
<div v-if="pendingAttaches.length" class="attach-preview">
|
||||
<div v-for="item in pendingAttaches" :key="item.id" class="attach-chip" :class="item.kind">
|
||||
<img v-if="item.kind === 'image'" :src="item.previewUrl" :alt="item.name" />
|
||||
<video v-else-if="item.kind === 'video'" :src="item.previewUrl" muted />
|
||||
<span v-else class="attach-chip-label">
|
||||
<UiIcon :name="item.kind === 'voice' ? 'mic' : 'document'" :size="14" />
|
||||
{{ item.name }}
|
||||
</span>
|
||||
<button type="button" class="attach-remove" aria-label="حذف" @click="removePending(item.id)">
|
||||
<UiIcon name="x" :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="recording" class="rec-bar">
|
||||
<span class="rec-dot" />
|
||||
در حال ضبط {{ toFaDigits(recordLabel()) }}
|
||||
<button type="button" class="rec-stop" @click="toggleVoiceRecord">توقف</button>
|
||||
</div>
|
||||
<div class="composer-row">
|
||||
<input
|
||||
ref="fileInputEl"
|
||||
type="file"
|
||||
class="sr-only"
|
||||
multiple
|
||||
accept="image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.zip,.rar,.txt"
|
||||
@change="onFilePicked"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="tool-btn"
|
||||
title="پیوست فایل / عکس / فیلم"
|
||||
:disabled="sending || recording"
|
||||
@click="fileInputEl?.click()"
|
||||
>
|
||||
<UiIcon name="document" :size="18" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tool-btn"
|
||||
:class="{ on: recording }"
|
||||
title="ضبط وویس"
|
||||
:disabled="sending"
|
||||
@click="toggleVoiceRecord"
|
||||
>
|
||||
<UiIcon :name="recording ? 'stop' : 'mic'" :size="18" />
|
||||
</button>
|
||||
<input
|
||||
v-model="draft"
|
||||
type="text"
|
||||
placeholder="پیام خود را بنویسید…"
|
||||
autocomplete="off"
|
||||
:disabled="sending || recording"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="sending || recording || (!draft.trim() && !pendingAttaches.length)"
|
||||
>
|
||||
<UiIcon name="send" :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
</section>
|
||||
@@ -408,6 +682,13 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog v-if="lightboxUrl" class="lightbox" open @click.self="lightboxUrl = ''">
|
||||
<button type="button" class="lightbox-close" aria-label="بستن" @click="lightboxUrl = ''">
|
||||
<UiIcon name="x" :size="18" />
|
||||
</button>
|
||||
<img :src="lightboxUrl" alt="" />
|
||||
</dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -656,11 +937,76 @@ onUnmounted(() => {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.bubble.media {
|
||||
padding: 0.45rem;
|
||||
}
|
||||
.media-image {
|
||||
position: relative;
|
||||
display: block;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
cursor: zoom-in;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
max-width: min(280px, 72vw);
|
||||
}
|
||||
.media-image img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 320px;
|
||||
object-fit: cover;
|
||||
}
|
||||
.upload-veil {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
color: #fff;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.media-audio,
|
||||
.media-video {
|
||||
display: block;
|
||||
width: min(280px, 72vw);
|
||||
max-width: 100%;
|
||||
}
|
||||
.media-video {
|
||||
border-radius: 12px;
|
||||
background: #0f172a;
|
||||
}
|
||||
.media-file {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.45rem 0.65rem;
|
||||
border-radius: 12px;
|
||||
background: rgba(15, 23, 42, 0.06);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-size: 0.88rem;
|
||||
max-width: min(280px, 72vw);
|
||||
}
|
||||
.media-file span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.upload-line {
|
||||
display: block;
|
||||
margin-top: 0.35rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.bubble-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.25rem;
|
||||
padding-inline: 0.25rem;
|
||||
}
|
||||
.bubble time {
|
||||
font-size: 0.68rem;
|
||||
@@ -684,27 +1030,177 @@ onUnmounted(() => {
|
||||
}
|
||||
.composer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem 1rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.composer input {
|
||||
.composer-row {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
}
|
||||
.composer-row input[type='text'] {
|
||||
flex: 1;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 0.65rem 0.85rem;
|
||||
font: inherit;
|
||||
}
|
||||
.composer button {
|
||||
.composer-row > button[type='submit'] {
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
background: #0d9488;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.composer-row > button[type='submit']:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.tool-btn {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-radius: 12px;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tool-btn.on {
|
||||
border-color: #ef4444;
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
.tool-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.attach-preview {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.15rem;
|
||||
}
|
||||
.attach-chip {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--line);
|
||||
overflow: hidden;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.attach-chip.image,
|
||||
.attach-chip.video {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
}
|
||||
.attach-chip img,
|
||||
.attach-chip video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.attach-chip-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
max-width: 160px;
|
||||
padding: 0.55rem 0.7rem;
|
||||
font-size: 0.78rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.attach-remove {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
background: rgba(15, 23, 42, 0.7);
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rec-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: #b91c1c;
|
||||
}
|
||||
.rec-dot {
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
border-radius: 50%;
|
||||
background: #ef4444;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
.rec-stop {
|
||||
margin-inline-start: auto;
|
||||
border: 1px solid #fecaca;
|
||||
background: #fff;
|
||||
color: #b91c1c;
|
||||
border-radius: 10px;
|
||||
padding: 0.25rem 0.65rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
@keyframes pulse {
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
.lightbox {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
border: none;
|
||||
background: rgba(15, 23, 42, 0.82);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.lightbox img {
|
||||
max-width: min(960px, 100%);
|
||||
max-height: 90vh;
|
||||
border-radius: 12px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.lightbox-close {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
left: 1rem;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.empty-stage {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
|
||||
@@ -563,11 +563,19 @@ onMounted(() => {
|
||||
</label>
|
||||
<label>
|
||||
<span>آدرس آپلود فایل</span>
|
||||
<input v-model="chatForm.uploadUrl" dir="ltr" />
|
||||
<input
|
||||
v-model="chatForm.uploadUrl"
|
||||
dir="ltr"
|
||||
placeholder="https://chat.example.ir/upload"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>آدرس پایه دانلود فایل</span>
|
||||
<input v-model="chatForm.downloadUrl" dir="ltr" />
|
||||
<input
|
||||
v-model="chatForm.downloadUrl"
|
||||
dir="ltr"
|
||||
placeholder="https://chat.example.ir/files"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user