Files
boursecu_admin/src/pages/support/components/AdminSupportComposer.vue
T
2026-07-30 18:30:32 +03:30

1003 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="admin-composer">
<div class="admin-composer__container">
<div
class="admin-composer__panel"
:class="{ 'admin-composer__panel--drag': isDragOver }"
@dragenter.prevent="isDragOver = true"
@dragover.prevent="isDragOver = true"
@dragleave.prevent="onDragLeave"
@drop.prevent="onDrop"
>
<input
ref="fileInputRef"
type="file"
accept="image/*"
multiple
class="hidden"
@change="onFileChange"
/>
<div v-if="attachments.length" class="admin-composer__attachments">
<div
v-for="(attachment, index) in attachments"
:key="attachment.id"
draggable="true"
class="admin-composer__attachment group"
:class="
attachment.type === 'image'
? 'admin-composer__attachment--image'
: 'admin-composer__attachment--audio'
"
@dragstart="onPreviewDragStart(index)"
@dragover.prevent
@drop.prevent="onPreviewDrop(index)"
>
<template v-if="attachment.type === 'image'">
<img
:src="attachment.url"
:alt="attachment.name"
class="admin-composer__attachment-image"
/>
</template>
<template v-else>
<div class="admin-composer__audio-badge">
<BaseIcon icon="vuesax-linear:microphone-2" size="18" />
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-semibold text-zinc-800">
{{ attachment.name }}
</div>
<audio :src="attachment.url" controls class="mt-2 block w-full max-w-[160px]" />
</div>
</template>
<BaseIcon
icon="vuesax-linear:close-circle"
@click="removeAttachment(attachment.id)"
class="admin-composer__remove cursor-pointer"
/>
</div>
</div>
<div
v-show="showRecorder || isRecording || recordedBlobUrl"
class="admin-composer__recorder"
>
<div class="admin-composer__recorder-head">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-2">
<span
v-if="isRecording"
class="h-2.5 w-2.5 rounded-full bg-red-500 animate-pulse"
/>
<span class="text-sm font-semibold text-zinc-800">
{{ isRecording ? 'در حال ضبط وویس...' : 'پیش‌نمایش وویس' }}
</span>
</div>
<span class="text-xs text-zinc-500">
{{ formattedRecordTime }}
</span>
</div>
</div>
<div class="px-3 py-3">
<div ref="waveformEl" class="min-h-[64px]" />
</div>
<div v-if="recordedBlobUrl && !isRecording" class="admin-composer__recorder-actions">
<BaseButton type="default" class="admin-secondary-btn" @click="discardRecordedVoice">
حذف وویس
</BaseButton>
<BaseButton
type="primary"
class="admin-primary-btn"
@click="appendRecordedVoiceToAttachments"
>
افزودن به پاسخ
</BaseButton>
</div>
</div>
<div v-if="replyTo" class="admin-composer__reply-preview">
<div class="admin-composer__reply-line" />
<div v-if="replyTo.attachment?.type === 'image'" class="admin-composer__reply-thumb">
<img :src="replyTo.attachment.url" :alt="replyTo.attachment.name || 'reply image'" />
</div>
<div class="admin-composer__reply-content">
<div class="admin-composer__reply-title">پاسخ به {{ replySenderLabel }}</div>
<div class="admin-composer__reply-text">
{{ replyPreviewText }}
</div>
</div>
<button type="button" class="admin-composer__reply-close" @click="$emit('cancel-reply')">
×
</button>
</div>
<div class="admin-composer__row">
<div class="admin-composer__input-shell">
<BaseButton
type="link"
class="admin-attach-btn"
:disabled="disabled || isRecording"
@click="openFilePicker"
>
<BaseIcon icon="vuesax-linear:gallery" size="20" />
</BaseButton>
<textarea
ref="textareaRef"
:value="inputModel"
:disabled="disabled || isRecording"
rows="1"
placeholder="پیامت را بنویس..."
class="admin-composer-textarea w-full resize-none overflow-y-auto bg-transparent px-2 py-[10px] text-sm leading-7 outline-none !text-[16px]"
@input="onInput"
@keydown="onComposerKeydown"
/>
</div>
<div class="admin-composer__actions !p-0">
<BaseButton
type="primary"
icon="vuesax-linear:direct-right"
class="admin-send-btn !p-0"
:disabled="
disabled || isRecording || (!inputModel.trim() && attachments.length === 0)
"
@click="submit"
/>
</div>
</div>
<div v-if="isDragOver" class="admin-composer__dropzone">عکس را اینجا رها کن</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import WaveSurfer from 'wavesurfer.js'
import RecordPlugin from 'wavesurfer.js/dist/plugins/record.esm.js'
import type { ComposerSendPayload, SupportAttachment, SupportMessageReplyTo } from '@/model/support'
const props = defineProps<{
value?: string
modelValue?: string
disabled?: boolean
replyTo?: SupportMessageReplyTo | null
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
(e: 'send', payload: ComposerSendPayload): void
(e: 'cancel-reply'): void
}>()
const fileInputRef = ref<HTMLInputElement | null>(null)
const waveformEl = ref<HTMLElement | null>(null)
const textareaRef = ref<HTMLTextAreaElement | null>(null)
const shouldRefocusAfterSend = ref(false)
const attachments = ref<SupportAttachment[]>([])
const isDragOver = ref(false)
const draggedPreviewIndex = ref<number | null>(null)
const showRecorder = ref(false)
const isRecording = ref(false)
const recordTimeMs = ref(0)
const recordedBlob = ref<Blob | null>(null)
const recordedBlobUrl = ref('')
const recordedMimeType = ref('audio/webm')
let wavesurfer: WaveSurfer | null = null
let recordPlugin: ReturnType<typeof RecordPlugin.create> | null = null
const createId = () => `${Date.now()}-${Math.floor(Math.random() * 100000)}`
const inputModel = ref(String(props.modelValue ?? ''))
watch(
() => props.modelValue,
async (value) => {
const normalized = String(value ?? '')
if (normalized !== inputModel.value) {
inputModel.value = normalized
}
await nextTick()
resizeTextarea()
},
{ immediate: true }
)
const formattedRecordTime = computed(() => {
const totalSeconds = Math.floor(recordTimeMs.value / 1000)
const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, '0')
const seconds = String(totalSeconds % 60).padStart(2, '0')
return `${minutes}:${seconds}`
})
const replySenderLabel = computed(() => {
if (props.replyTo?.sender === 'admin') return 'شما'
if (props.replyTo?.sender === 'user') return 'کاربر'
return 'پیام'
})
const replyPreviewText = computed(() => {
return String(props.replyTo?.text || 'پیام').trim()
})
const resizeTextarea = () => {
const el = textareaRef.value
if (!el) return
el.style.height = 'auto'
const nextHeight = Math.min(el.scrollHeight, 144)
el.style.height = `${nextHeight}px`
el.style.overflowY = el.scrollHeight > 144 ? 'auto' : 'hidden'
}
const focusTextarea = async () => {
await nextTick()
const el = textareaRef.value
if (!el || props.disabled || isRecording.value) return
el.focus({ preventScroll: true })
const length = el.value.length
el.setSelectionRange(length, length)
}
const onInput = (event: Event) => {
const target = event.target as HTMLTextAreaElement
inputModel.value = target.value
emit('update:modelValue', target.value)
resizeTextarea()
}
const onComposerKeydown = (event: KeyboardEvent) => {
if ((event as KeyboardEvent & { isComposing?: boolean }).isComposing || event.keyCode === 229) {
return
}
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
submit()
}
}
const openFilePicker = () => {
fileInputRef.value?.click()
}
const createImageAttachment = (file: File): SupportAttachment => ({
id: createId(),
type: 'image',
url: URL.createObjectURL(file),
name: file.name,
size: file.size,
mimeType: file.type,
file
})
const createAudioAttachment = (file: File, durationMs?: number): SupportAttachment => ({
id: createId(),
type: 'audio',
url: URL.createObjectURL(file),
name: file.name,
size: file.size,
mimeType: file.type,
durationMs,
file
})
const addImageFiles = (files: File[]) => {
const imageFiles = files.filter((file) => file.type.startsWith('image/'))
if (!imageFiles.length) return
attachments.value.push(...imageFiles.map(createImageAttachment))
}
const onFileChange = (event: Event) => {
const target = event.target as HTMLInputElement
const files = Array.from(target.files ?? [])
addImageFiles(files)
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
const onDrop = (event: DragEvent) => {
isDragOver.value = false
const files = Array.from(event.dataTransfer?.files ?? [])
addImageFiles(files)
}
const onDragLeave = (event: DragEvent) => {
const target = event.currentTarget as HTMLElement | null
const relatedTarget = event.relatedTarget as Node | null
if (!target || (relatedTarget && target.contains(relatedTarget))) return
isDragOver.value = false
}
const removeAttachment = (id: string) => {
const target = attachments.value.find((item) => item.id === id)
if (target?.url.startsWith('blob:')) {
URL.revokeObjectURL(target.url)
}
attachments.value = attachments.value.filter((item) => item.id !== id)
}
const onPreviewDragStart = (index: number) => {
draggedPreviewIndex.value = index
}
const onPreviewDrop = (dropIndex: number) => {
if (draggedPreviewIndex.value === null) return
if (draggedPreviewIndex.value === dropIndex) return
const items = [...attachments.value]
const movedItem = items[draggedPreviewIndex.value]
items.splice(draggedPreviewIndex.value, 1)
items.splice(dropIndex, 0, movedItem)
attachments.value = items
draggedPreviewIndex.value = null
}
const requestMicrophonePermission = async () => {
const hasMediaDevices =
typeof navigator !== 'undefined' &&
!!navigator.mediaDevices &&
typeof navigator.mediaDevices.getUserMedia === 'function'
if (!hasMediaDevices) {
alert('ضبط وویس فقط روی localhost یا https کار می‌کند.')
return false
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
stream.getTracks().forEach((track) => track.stop())
return true
} catch (error) {
console.error('mic permission failed:', error)
alert('دسترسی میکروفون گرفته نشد.')
return false
}
}
const initWaveSurfer = async () => {
if (!waveformEl.value || wavesurfer) return
recordPlugin = RecordPlugin.create({
scrollingWaveform: true,
renderRecordedAudio: true,
mediaRecorderTimeslice: 250,
mimeType: 'audio/webm'
})
wavesurfer = WaveSurfer.create({
container: waveformEl.value,
height: 64,
cursorWidth: 0,
barWidth: 3,
barGap: 2,
barRadius: 999,
normalize: true,
interact: true,
plugins: [recordPlugin]
})
recordPlugin.on('record-progress', (duration) => {
recordTimeMs.value = duration
})
recordPlugin.on('record-start', () => {
isRecording.value = true
recordTimeMs.value = 0
recordedBlob.value = null
if (recordedBlobUrl.value) {
URL.revokeObjectURL(recordedBlobUrl.value)
recordedBlobUrl.value = ''
}
})
recordPlugin.on('record-end', (blob) => {
isRecording.value = false
recordedBlob.value = blob
recordedMimeType.value = blob.type || 'audio/webm'
if (recordedBlobUrl.value) {
URL.revokeObjectURL(recordedBlobUrl.value)
}
recordedBlobUrl.value = URL.createObjectURL(blob)
})
}
const toggleRecording = async () => {
if (props.disabled) return
const granted = await requestMicrophonePermission()
if (!granted) return
try {
if (!showRecorder.value) {
showRecorder.value = true
await nextTick()
}
await initWaveSurfer()
if (!recordPlugin) return
if (recordPlugin.isRecording()) {
recordPlugin.stopRecording()
} else {
await recordPlugin.startRecording()
}
} catch (error) {
console.error('Voice recording failed:', error)
alert('ضبط وویس شروع نشد.')
}
}
const discardRecordedVoice = () => {
recordedBlob.value = null
recordTimeMs.value = 0
isRecording.value = false
if (recordedBlobUrl.value) {
URL.revokeObjectURL(recordedBlobUrl.value)
recordedBlobUrl.value = ''
}
showRecorder.value = false
wavesurfer?.empty()
}
const appendRecordedVoiceToAttachments = () => {
if (!recordedBlob.value) return
const extension = recordedMimeType.value.includes('ogg')
? 'ogg'
: recordedMimeType.value.includes('mp4')
? 'm4a'
: 'webm'
const file = new File([recordedBlob.value], `voice-${Date.now()}.${extension}`, {
type: recordedMimeType.value || 'audio/webm'
})
attachments.value.push(createAudioAttachment(file, recordTimeMs.value))
discardRecordedVoice()
}
const clearComposerAttachments = () => {
attachments.value.forEach((item) => {
if (item.url.startsWith('blob:')) {
URL.revokeObjectURL(item.url)
}
})
attachments.value = []
}
const resetComposer = () => {
inputModel.value = ''
emit('update:modelValue', '')
clearComposerAttachments()
}
const submit = async () => {
const text = inputModel.value.trim()
if (!text && attachments.value.length === 0) return
if (props.disabled || isRecording.value) return
const payloadAttachments = attachments.value.map((item) => ({
...item
}))
shouldRefocusAfterSend.value = true
resetComposer()
await nextTick()
resizeTextarea()
emit('send', {
text,
attachments: payloadAttachments,
replyTo: props.replyTo || null
})
await nextTick()
if (!props.disabled) {
shouldRefocusAfterSend.value = false
requestAnimationFrame(() => {
focusTextarea()
resizeTextarea()
})
}
}
onBeforeUnmount(() => {
clearComposerAttachments()
if (recordedBlobUrl.value) {
URL.revokeObjectURL(recordedBlobUrl.value)
}
recordPlugin?.destroy()
wavesurfer?.destroy()
recordPlugin = null
wavesurfer = null
})
watch(
() => props.disabled,
async (disabled, oldDisabled) => {
if (oldDisabled && !disabled && shouldRefocusAfterSend.value) {
shouldRefocusAfterSend.value = false
await nextTick()
requestAnimationFrame(() => {
focusTextarea()
resizeTextarea()
})
}
}
)
</script>
<style lang="less">
.admin-composer {
flex-shrink: 0;
position: relative;
z-index: 2;
background: var(--admin-bg-elevated, #fff);
border-top: 1px solid var(--admin-border, #e5e7eb);
}
.admin-composer__container {
max-width: 64rem;
margin: 0 auto;
padding: 10px 12px;
}
.admin-composer__panel {
border: 1px solid var(--admin-border-strong, #d9e5ff);
border-radius: 16px;
background: var(--admin-bg-muted, #f5f8ff);
padding: 10px;
transition: 0.2s ease;
box-shadow: none;
}
.admin-composer__panel--drag {
border-color: var(--admin-gold-light, #9db8ff);
background: color-mix(in srgb, var(--admin-gold-light, #2563eb) 12%, var(--admin-bg-muted, #eef4ff));
}
.admin-composer__reply-preview {
position: relative;
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
padding: 10px 12px;
border: 1px solid var(--admin-border, #cfe0ff);
border-radius: 14px;
background: var(--admin-bg-elevated, #fff);
direction: rtl;
}
.admin-composer__reply-line {
width: 4px;
height: 36px;
flex-shrink: 0;
border-radius: 999px;
background: var(--colorPrimary, #2563eb);
}
.admin-composer__reply-content {
min-width: 0;
flex: 1;
}
.admin-composer__reply-title {
margin-bottom: 2px;
font-size: 12px;
font-weight: 900;
color: var(--colorPrimary, #2563eb);
}
.admin-composer__reply-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 60vw;
font-size: 12px;
line-height: 1.8;
color: var(--admin-text-muted, #52525b);
}
.admin-composer__reply-close {
width: 28px;
height: 28px;
flex-shrink: 0;
border: 0;
border-radius: 999px;
background: var(--admin-bg-muted, #eef2ff);
color: #334155;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 20px;
line-height: 1;
}
.admin-composer__attachments {
display: flex;
gap: 8px;
overflow-x: auto;
padding-bottom: 4px;
margin-bottom: 10px;
}
.admin-composer__attachment {
position: relative;
flex-shrink: 0;
overflow: hidden;
border-radius: 16px;
border: 1px solid #dbe3f0;
background: #fff;
}
.admin-composer__attachment--image {
width: 88px;
height: 88px;
}
.admin-composer__attachment--audio {
display: flex;
align-items: center;
gap: 12px;
min-width: 240px;
padding: 12px;
}
.admin-composer__attachment-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.admin-composer__audio-badge {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 14px;
background: #18181b;
color: #fff;
}
.admin-composer__remove {
position: absolute;
top: 6px;
right: 6px;
background: rgba(239, 68, 68, 0.92);
color: #fff;
padding: 2px;
border-radius: 9999px;
font-size: 18px;
}
.admin-composer__recorder {
margin-bottom: 10px;
overflow: hidden;
border-radius: 16px;
border: 1px solid #dbe3f0;
background: #fff;
}
.admin-composer__recorder-head {
border-bottom: 1px solid #eef2f7;
padding: 12px 16px;
}
.admin-composer__recorder-actions {
display: flex;
align-items: center;
gap: 8px;
border-top: 1px solid #eef2f7;
padding: 12px 16px;
}
.admin-composer__row {
display: flex;
align-items: center;
gap: 10px;
direction: ltr;
}
.admin-composer__actions {
flex-shrink: 0;
display: flex;
align-items: center;
}
.admin-composer__input-shell {
min-width: 0;
flex: 1;
display: flex;
align-items: center;
border: 1px solid var(--admin-border, #cfe0ff);
border-radius: 14px;
background: var(--admin-bg-elevated, #fff);
padding: 0 10px;
}
.admin-composer__dropzone {
margin-top: 10px;
border: 1px dashed var(--admin-border-strong, #cbd5e1);
border-radius: 14px;
background: color-mix(in srgb, var(--admin-bg-elevated, #fff) 88%, transparent);
padding: 12px 16px;
text-align: center;
font-size: 13px;
color: var(--admin-text-secondary, #64748b);
}
.admin-composer-textarea {
display: block;
width: 100%;
min-height: 48px;
max-height: 144px;
border: 0;
background: transparent;
box-shadow: none;
resize: none;
direction: rtl;
text-align: right;
color: var(--admin-text, var(--colorText, #111827));
font-family: var(--font-family, iran-sans), serif;
scrollbar-width: thin;
scrollbar-color: rgb(var(--colorPrimaryRGB, 37 99 235) / 0.35) transparent;
}
.admin-composer-textarea::placeholder {
color: var(--admin-text-secondary, color-mix(in srgb, var(--colorText, #111827) 45%, transparent));
}
.admin-composer-textarea::-webkit-scrollbar {
width: 6px;
}
.admin-composer-textarea::-webkit-scrollbar-thumb {
background: rgb(var(--colorPrimaryRGB, 37 99 235) / 0.35);
border-radius: 999px;
}
.admin-composer-textarea::-webkit-scrollbar-track {
background: transparent;
}
.admin-composer-textarea:focus {
outline: none;
}
.admin-composer__actions {
.ant-btn {
width: 50px !important;
height: 50px !important;
min-width: 50px !important;
border-radius: 14px !important;
border: 0 !important;
color: #fff !important;
box-shadow: none !important;
}
.ant-btn:hover,
.ant-btn:focus {
//background: #7d9bf3 !important;
color: #fff !important;
}
.ant-btn[disabled] {
background: #c7d4fb !important;
color: #fff !important;
}
}
.admin-primary-btn {
.ant-btn {
border-radius: 14px !important;
border-color: #18181b !important;
background: #18181b !important;
color: #fff !important;
box-shadow: none !important;
}
}
.admin-secondary-btn {
.ant-btn {
border-radius: 14px !important;
border: 1px solid #e4e4e7 !important;
background: #fff !important;
color: #3f3f46 !important;
box-shadow: none !important;
}
}
@media (max-width: 767px) {
.admin-composer__container {
padding: 8px 10px;
}
.admin-composer__panel {
border-radius: 14px;
padding: 8px;
}
.admin-composer__row {
gap: 8px;
direction: ltr;
}
.admin-composer__actions {
.ant-btn {
width: 50px !important;
height: 50px !important;
min-width: 50px !important;
border-radius: 14px !important;
}
}
.admin-composer-textarea {
textarea.ant-input {
min-height: 50px;
padding: 12px 14px;
border-radius: 14px;
direction: rtl;
text-align: right;
}
}
}
.admin-composer__actions {
gap: 8px;
}
.admin-attach-btn {
width: 30px;
height: 50px;
min-width: 20px;
border: unset;
background: #fff;
color: var(--colorPrimary, #2563eb);
display: inline-flex;
align-items: center;
justify-content: center;
}
.admin-attach-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.admin-composer__reply-thumb {
width: 44px;
height: 44px;
flex: 0 0 44px;
overflow: hidden;
border-radius: 12px;
background: #eef2ff;
}
.admin-composer__reply-thumb img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
@media (min-width: 768px) {
.admin-composer__container {
padding: 12px 14px;
}
}
/* dark mode */
html.is-dark .admin-composer {
background: var(--admin-bg-elevated);
border-top-color: var(--admin-border);
}
html.is-dark .admin-composer__panel {
border-color: var(--admin-border-strong);
background: var(--admin-bg-muted);
}
html.is-dark .admin-composer__panel--drag {
border-color: var(--admin-gold-light);
background: color-mix(in srgb, var(--admin-gold-light) 12%, var(--admin-bg-muted));
}
html.is-dark .admin-composer__reply-preview,
html.is-dark .admin-composer__input-shell,
html.is-dark .admin-composer__attachment,
html.is-dark .admin-composer__recorder {
background: var(--admin-bg-elevated) !important;
border-color: var(--admin-border) !important;
color: var(--admin-text) !important;
}
html.is-dark .admin-composer__reply-title {
color: var(--admin-gold-light) !important;
}
html.is-dark .admin-composer__reply-text {
color: var(--admin-text-muted) !important;
}
html.is-dark .admin-composer__reply-close {
color: var(--admin-text-muted) !important;
}
html.is-dark .admin-composer-textarea {
color: var(--admin-text) !important;
}
html.is-dark .admin-composer-textarea::placeholder {
color: var(--admin-text-secondary) !important;
}
html.is-dark .admin-composer__dropzone {
background: color-mix(in srgb, var(--admin-bg-elevated) 88%, transparent);
border-color: var(--admin-border-strong);
color: var(--admin-text-secondary);
}
html.is-dark .admin-composer__audio-badge {
color: var(--admin-text);
}
html.is-dark .admin-attach-btn {
background: transparent !important;
color: var(--admin-gold-light, var(--colorPrimary, #2563eb)) !important;
}
html.is-dark .admin-attach-btn:hover {
background: color-mix(in srgb, var(--admin-gold-light, #2563eb) 12%, transparent) !important;
}
html.is-dark .admin-composer__reply-thumb {
background: var(--admin-bg-elevated);
border: 1px solid var(--admin-border);
}
html.is-dark .admin-composer__remove {
background: var(--admin-bg-elevated);
color: var(--admin-text-secondary);
border-color: var(--admin-border);
}
</style>