Files
boursecu_admin/src/pages/support/components/AdminSupportComposer.vue
T

1003 lines
24 KiB
Vue
Raw Normal View History

2026-07-30 18:30:32 +03:30
<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>