更新
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
<template>
|
||||
<div class="msg-bubble" :class="{ 'is-staff': fromStaff }">
|
||||
<el-avatar class="msg-avatar" :size="36" :src="avatarUrl">
|
||||
{{ shortName }}
|
||||
</el-avatar>
|
||||
<div class="msg-body">
|
||||
<div class="msg-meta">
|
||||
<span class="msg-sender">{{ senderName }}</span>
|
||||
<span class="msg-time">{{ formatTime(message.send_time) }}</span>
|
||||
<el-tag
|
||||
v-if="message.action === 'recall'"
|
||||
size="small"
|
||||
type="info"
|
||||
>
|
||||
已撤回
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="msg-content" :class="bubbleClass">
|
||||
<!-- 文本 / markdown -->
|
||||
<div
|
||||
v-if="message.msgtype === 'text' || message.msgtype === 'markdown'"
|
||||
class="content-text"
|
||||
>
|
||||
{{ message.content }}
|
||||
</div>
|
||||
|
||||
<!-- 图片 -->
|
||||
<el-image
|
||||
v-else-if="message.msgtype === 'image' && firstMedia?.file_url"
|
||||
:src="firstMedia.file_url"
|
||||
:preview-src-list="[firstMedia.file_url]"
|
||||
fit="contain"
|
||||
class="content-image"
|
||||
hide-on-click-modal
|
||||
/>
|
||||
<div v-else-if="message.msgtype === 'image'" class="content-pending">
|
||||
<el-icon><Picture /></el-icon>
|
||||
<span>图片下载中…</span>
|
||||
</div>
|
||||
|
||||
<!-- 视频 -->
|
||||
<video
|
||||
v-else-if="message.msgtype === 'video' && firstMedia?.file_url"
|
||||
:src="firstMedia.file_url"
|
||||
controls
|
||||
class="content-video"
|
||||
/>
|
||||
<div v-else-if="message.msgtype === 'video'" class="content-pending">
|
||||
<el-icon><VideoCamera /></el-icon>
|
||||
<span>视频下载中…</span>
|
||||
</div>
|
||||
|
||||
<!-- 语音 -->
|
||||
<audio
|
||||
v-else-if="message.msgtype === 'voice' && firstMedia?.file_url"
|
||||
:src="firstMedia.file_url"
|
||||
controls
|
||||
class="content-audio"
|
||||
/>
|
||||
<div v-else-if="message.msgtype === 'voice'" class="content-pending">
|
||||
<el-icon><Microphone /></el-icon>
|
||||
<span>语音下载中…({{ message.play_length }}s)</span>
|
||||
</div>
|
||||
|
||||
<!-- 文件 -->
|
||||
<div v-else-if="message.msgtype === 'file'" class="content-file">
|
||||
<el-icon :size="24"><Document /></el-icon>
|
||||
<div class="file-meta">
|
||||
<div class="file-name" :title="message.file_name">
|
||||
{{ message.file_name || '未命名文件' }}
|
||||
</div>
|
||||
<div class="file-info">
|
||||
{{ formatSize(message.file_size) }}
|
||||
<template v-if="message.file_ext">· {{ message.file_ext }}</template>
|
||||
</div>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="firstMedia?.file_url"
|
||||
size="small"
|
||||
type="primary"
|
||||
link
|
||||
@click="openFile(firstMedia.file_url)"
|
||||
>
|
||||
下载
|
||||
</el-button>
|
||||
<el-tag v-else size="small" type="info">下载中…</el-tag>
|
||||
</div>
|
||||
|
||||
<!-- 链接 / 小程序 / 聊天记录 / 位置等 -->
|
||||
<div v-else class="content-fallback">
|
||||
<div class="fallback-title">{{ fallbackLabel }}</div>
|
||||
<div class="fallback-desc">{{ message.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Document, Microphone, Picture, VideoCamera } from '@element-plus/icons-vue'
|
||||
import type { QywxMsgArchiveItem } from '@/api/qywx-msg'
|
||||
import { timeFormat } from '@/utils/util'
|
||||
|
||||
const props = defineProps<{ message: QywxMsgArchiveItem }>()
|
||||
|
||||
const fromStaff = computed(() => props.message.from_is_staff)
|
||||
|
||||
const firstMedia = computed(() => {
|
||||
const list = props.message.media || []
|
||||
return list.find((m) => m.status === 1) ?? null
|
||||
})
|
||||
|
||||
const senderName = computed(() => {
|
||||
const p = props.message.from_profile as any
|
||||
if (!p) return props.message.from_user
|
||||
return p.name || props.message.from_user
|
||||
})
|
||||
|
||||
const avatarUrl = computed(() => {
|
||||
const p = props.message.from_profile as any
|
||||
return p?.avatar || ''
|
||||
})
|
||||
|
||||
const shortName = computed(() => {
|
||||
const name = senderName.value || ''
|
||||
return name.slice(-2) || '?'
|
||||
})
|
||||
|
||||
const bubbleClass = computed(() => {
|
||||
const t = props.message.msgtype
|
||||
if (t === 'image' || t === 'video' || t === 'voice') return 'bubble-media'
|
||||
if (t === 'file') return 'bubble-file'
|
||||
return 'bubble-text'
|
||||
})
|
||||
|
||||
const fallbackLabel = computed(() => {
|
||||
switch (props.message.msgtype) {
|
||||
case 'link':
|
||||
return '[链接]'
|
||||
case 'weapp':
|
||||
return '[小程序]'
|
||||
case 'chatrecord':
|
||||
return '[聊天记录]'
|
||||
case 'location':
|
||||
return '[位置]'
|
||||
case 'card':
|
||||
return '[名片]'
|
||||
case 'meeting':
|
||||
return '[会议]'
|
||||
case 'docmsg':
|
||||
return '[文档]'
|
||||
case 'mixed':
|
||||
return '[混合消息]'
|
||||
case 'emotion':
|
||||
return '[表情]'
|
||||
default:
|
||||
return `[${props.message.msgtype}]`
|
||||
}
|
||||
})
|
||||
|
||||
function formatTime(sec: number) {
|
||||
if (!sec) return ''
|
||||
return timeFormat(sec * 1000, 'mm-dd hh:MM')
|
||||
}
|
||||
|
||||
function formatSize(bytes: number) {
|
||||
if (!bytes) return ''
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(2)} MB`
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
|
||||
}
|
||||
|
||||
function openFile(url: string) {
|
||||
window.open(url, '_blank', 'noopener')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.msg-bubble {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 10px 20px;
|
||||
align-items: flex-start;
|
||||
|
||||
&.is-staff {
|
||||
flex-direction: row-reverse;
|
||||
|
||||
.msg-body {
|
||||
align-items: flex-end;
|
||||
}
|
||||
.msg-content.bubble-text,
|
||||
.msg-content.bubble-file {
|
||||
background: #95ec69;
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
|
||||
.msg-avatar {
|
||||
flex-shrink: 0;
|
||||
background: #d0d7de;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.msg-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-width: 65%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.msg-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.msg-sender {
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.msg-content {
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
word-break: break-word;
|
||||
line-height: 1.6;
|
||||
max-width: 100%;
|
||||
|
||||
&.bubble-media {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.content-text {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.content-image {
|
||||
max-width: 300px;
|
||||
max-height: 300px;
|
||||
border-radius: 6px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.content-video {
|
||||
max-width: 320px;
|
||||
max-height: 320px;
|
||||
border-radius: 6px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.content-audio {
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
.content-file {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
min-width: 220px;
|
||||
padding: 4px 8px;
|
||||
|
||||
.file-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.file-name {
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.file-info {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
}
|
||||
|
||||
.content-pending {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.content-fallback {
|
||||
.fallback-title {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.fallback-desc {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,471 @@
|
||||
<template>
|
||||
<div class="send-panel">
|
||||
<div v-if="attachments.length" class="attachment-list">
|
||||
<div
|
||||
v-for="(att, idx) in attachments"
|
||||
:key="idx"
|
||||
class="attachment-item"
|
||||
:class="'attachment-' + att.msgtype"
|
||||
>
|
||||
<template v-if="att.msgtype === 'image'">
|
||||
<el-image
|
||||
:src="att.preview || ''"
|
||||
fit="cover"
|
||||
class="attachment-preview"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="att.msgtype === 'video'">
|
||||
<video :src="att.preview || ''" class="attachment-preview" muted />
|
||||
</template>
|
||||
<template v-else-if="att.msgtype === 'file'">
|
||||
<el-icon :size="20"><Document /></el-icon>
|
||||
<span class="attachment-filename">{{ att.file_name }}</span>
|
||||
</template>
|
||||
<template v-else-if="att.msgtype === 'link'">
|
||||
<el-icon :size="20"><Link /></el-icon>
|
||||
<span class="attachment-filename">{{ att.link?.title || att.link?.url }}</span>
|
||||
</template>
|
||||
<template v-else-if="att.msgtype === 'miniprogram'">
|
||||
<el-icon :size="20"><Grid /></el-icon>
|
||||
<span class="attachment-filename">{{ att.miniprogram?.title }}</span>
|
||||
</template>
|
||||
|
||||
<el-button
|
||||
v-if="att.uploading"
|
||||
type="primary"
|
||||
link
|
||||
loading
|
||||
size="small"
|
||||
/>
|
||||
<el-button
|
||||
v-else
|
||||
class="attachment-remove"
|
||||
type="danger"
|
||||
link
|
||||
@click="removeAttachment(idx)"
|
||||
>
|
||||
<el-icon><Close /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-input
|
||||
v-model="textContent"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="在此输入消息,Ctrl+Enter 发送。该消息以代发员工身份发给客户,员工手机端确认后送达。"
|
||||
resize="none"
|
||||
@keydown.enter.ctrl="onSend"
|
||||
/>
|
||||
|
||||
<div class="send-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<el-tooltip content="图片" placement="top">
|
||||
<el-button link @click="triggerFile('image')">
|
||||
<el-icon :size="18"><Picture /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="视频" placement="top">
|
||||
<el-button link @click="triggerFile('video')">
|
||||
<el-icon :size="18"><VideoCamera /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="文件" placement="top">
|
||||
<el-button link @click="triggerFile('file')">
|
||||
<el-icon :size="18"><Document /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="链接" placement="top">
|
||||
<el-button link @click="showLinkDialog = true">
|
||||
<el-icon :size="18"><Link /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="小程序" placement="top">
|
||||
<el-button link @click="showMiniprogramDialog = true">
|
||||
<el-icon :size="18"><Grid /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<span class="send-hint">
|
||||
员工手机端确认后送达客户 · Ctrl+Enter 快速发送
|
||||
</span>
|
||||
<el-button
|
||||
type="primary"
|
||||
:disabled="!canSend"
|
||||
:loading="sending"
|
||||
@click="onSend"
|
||||
>
|
||||
发送
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input ref="fileInputRef" type="file" hidden @change="onFileSelected" />
|
||||
|
||||
<!-- 链接弹窗 -->
|
||||
<el-dialog v-model="showLinkDialog" title="添加链接" width="480px">
|
||||
<el-form label-width="80px" :model="linkForm">
|
||||
<el-form-item label="标题">
|
||||
<el-input v-model="linkForm.title" maxlength="128" />
|
||||
</el-form-item>
|
||||
<el-form-item label="链接地址">
|
||||
<el-input v-model="linkForm.url" placeholder="https://..." />
|
||||
</el-form-item>
|
||||
<el-form-item label="封面图">
|
||||
<div class="link-picurl-row">
|
||||
<el-input v-model="linkForm.picurl" placeholder="https://..." />
|
||||
<el-button @click="triggerLinkImage">本地上传</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="linkForm.desc" type="textarea" :rows="2" maxlength="512" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showLinkDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="confirmLink">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 小程序弹窗 -->
|
||||
<el-dialog v-model="showMiniprogramDialog" title="添加小程序" width="480px">
|
||||
<el-form label-width="100px" :model="miniForm">
|
||||
<el-form-item label="标题">
|
||||
<el-input v-model="miniForm.title" />
|
||||
</el-form-item>
|
||||
<el-form-item label="appid">
|
||||
<el-input v-model="miniForm.appid" placeholder="wx..." />
|
||||
</el-form-item>
|
||||
<el-form-item label="页面路径">
|
||||
<el-input v-model="miniForm.page" placeholder="/pages/index/index" />
|
||||
</el-form-item>
|
||||
<el-form-item label="封面图">
|
||||
<el-button @click="triggerMiniImage">
|
||||
{{ miniForm.pic_media_id ? '已选择 · 重新选择' : '上传封面(必填)' }}
|
||||
</el-button>
|
||||
<div v-if="miniForm.preview" class="mini-preview">
|
||||
<el-image :src="miniForm.preview" fit="cover" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showMiniprogramDialog = false">取消</el-button>
|
||||
<el-button type="primary" :disabled="!miniForm.pic_media_id || !miniForm.appid" @click="confirmMiniprogram">
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { Close, Document, Grid, Link, Picture, VideoCamera } from '@element-plus/icons-vue'
|
||||
import feedback from '@/utils/feedback'
|
||||
import {
|
||||
qywxMsgUploadMedia,
|
||||
type QywxMsgAttachment,
|
||||
type QywxSendPayload
|
||||
} from '@/api/qywx-msg'
|
||||
import { uploadImageBlob } from '@/api/file'
|
||||
|
||||
interface AttachmentViewModel extends QywxMsgAttachment {
|
||||
uploading?: boolean
|
||||
preview?: string
|
||||
file_name?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
canSendMessage: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'send', payload: QywxSendPayload): Promise<void> | void
|
||||
}>()
|
||||
|
||||
const textContent = ref('')
|
||||
const attachments = ref<AttachmentViewModel[]>([])
|
||||
const sending = ref(false)
|
||||
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
const pendingUploadType = ref<'image' | 'video' | 'file' | 'link-cover' | 'mini-cover'>('image')
|
||||
|
||||
const showLinkDialog = ref(false)
|
||||
const linkForm = ref({ title: '', url: '', picurl: '', desc: '' })
|
||||
|
||||
const showMiniprogramDialog = ref(false)
|
||||
const miniForm = ref({
|
||||
title: '',
|
||||
appid: '',
|
||||
page: '',
|
||||
pic_media_id: '',
|
||||
preview: ''
|
||||
})
|
||||
|
||||
const canSend = computed(() => {
|
||||
if (!props.canSendMessage) return false
|
||||
if (sending.value) return false
|
||||
const hasText = textContent.value.trim().length > 0
|
||||
const hasAtt = attachments.value.some((a) => !a.uploading)
|
||||
return hasText || hasAtt
|
||||
})
|
||||
|
||||
function triggerFile(type: 'image' | 'video' | 'file') {
|
||||
if (attachments.value.length >= 9) {
|
||||
feedback.msgWarning('最多添加 9 个附件')
|
||||
return
|
||||
}
|
||||
pendingUploadType.value = type
|
||||
const accept =
|
||||
type === 'image' ? 'image/*' : type === 'video' ? 'video/*' : '*/*'
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.accept = accept
|
||||
fileInputRef.value.value = ''
|
||||
fileInputRef.value.click()
|
||||
}
|
||||
}
|
||||
|
||||
function triggerLinkImage() {
|
||||
pendingUploadType.value = 'link-cover'
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.accept = 'image/*'
|
||||
fileInputRef.value.value = ''
|
||||
fileInputRef.value.click()
|
||||
}
|
||||
}
|
||||
|
||||
function triggerMiniImage() {
|
||||
pendingUploadType.value = 'mini-cover'
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.accept = 'image/*'
|
||||
fileInputRef.value.value = ''
|
||||
fileInputRef.value.click()
|
||||
}
|
||||
}
|
||||
|
||||
async function onFileSelected(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
const type = pendingUploadType.value
|
||||
if (type === 'link-cover') {
|
||||
try {
|
||||
const uploaded = await uploadImageBlob(file, file.name)
|
||||
linkForm.value.picurl = uploaded.uri || uploaded.url
|
||||
feedback.msgSuccess('封面上传成功')
|
||||
} catch (err: any) {
|
||||
feedback.msgError(err?.message || '封面上传失败')
|
||||
}
|
||||
return
|
||||
}
|
||||
if (type === 'mini-cover') {
|
||||
try {
|
||||
const res: any = await qywxMsgUploadMedia(file, 'image')
|
||||
miniForm.value.pic_media_id = res.media_id
|
||||
const previewRes = await uploadImageBlob(file, file.name)
|
||||
miniForm.value.preview = previewRes.uri || previewRes.url
|
||||
feedback.msgSuccess('小程序封面上传成功')
|
||||
} catch (err: any) {
|
||||
feedback.msgError(err?.message || '封面上传失败')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 常规附件:image / video / file
|
||||
const preview = type === 'file' ? '' : URL.createObjectURL(file)
|
||||
const vm: AttachmentViewModel = {
|
||||
msgtype: type,
|
||||
uploading: true,
|
||||
preview,
|
||||
file_name: file.name
|
||||
}
|
||||
attachments.value.push(vm)
|
||||
const idx = attachments.value.length - 1
|
||||
|
||||
try {
|
||||
const res: any = await qywxMsgUploadMedia(file, type)
|
||||
const mediaId = res.media_id as string
|
||||
if (type === 'image') {
|
||||
vm.image = { media_id: mediaId }
|
||||
} else if (type === 'video') {
|
||||
vm.video = { media_id: mediaId }
|
||||
} else {
|
||||
vm.file = { media_id: mediaId }
|
||||
}
|
||||
vm.uploading = false
|
||||
} catch (err: any) {
|
||||
feedback.msgError(err?.message || '上传失败')
|
||||
attachments.value.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function removeAttachment(idx: number) {
|
||||
const vm = attachments.value[idx]
|
||||
if (vm?.preview && vm.preview.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(vm.preview)
|
||||
}
|
||||
attachments.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
function confirmLink() {
|
||||
const { title, url, picurl, desc } = linkForm.value
|
||||
if (!title.trim() || !url.trim() || !picurl.trim()) {
|
||||
feedback.msgWarning('请完整填写标题、链接地址与封面图')
|
||||
return
|
||||
}
|
||||
if (attachments.value.length >= 9) {
|
||||
feedback.msgWarning('最多添加 9 个附件')
|
||||
return
|
||||
}
|
||||
attachments.value.push({
|
||||
msgtype: 'link',
|
||||
link: { title, url, picurl, desc }
|
||||
})
|
||||
showLinkDialog.value = false
|
||||
linkForm.value = { title: '', url: '', picurl: '', desc: '' }
|
||||
}
|
||||
|
||||
function confirmMiniprogram() {
|
||||
const { title, appid, page, pic_media_id } = miniForm.value
|
||||
if (!title.trim() || !appid.trim() || !page.trim() || !pic_media_id) {
|
||||
feedback.msgWarning('请完整填写小程序信息')
|
||||
return
|
||||
}
|
||||
if (attachments.value.length >= 9) {
|
||||
feedback.msgWarning('最多添加 9 个附件')
|
||||
return
|
||||
}
|
||||
attachments.value.push({
|
||||
msgtype: 'miniprogram',
|
||||
miniprogram: { title, appid, page, pic_media_id }
|
||||
})
|
||||
showMiniprogramDialog.value = false
|
||||
miniForm.value = { title: '', appid: '', page: '', pic_media_id: '', preview: '' }
|
||||
}
|
||||
|
||||
async function onSend() {
|
||||
if (!canSend.value) return
|
||||
// 去掉 VM-only 字段,保留企微接口字段
|
||||
const pureAtts: QywxMsgAttachment[] = attachments.value
|
||||
.filter((a) => !a.uploading)
|
||||
.map((a) => {
|
||||
const base: QywxMsgAttachment = { msgtype: a.msgtype }
|
||||
if (a.image) base.image = a.image
|
||||
if (a.video) base.video = a.video
|
||||
if (a.file) base.file = a.file
|
||||
if (a.link) base.link = a.link
|
||||
if (a.miniprogram) base.miniprogram = a.miniprogram
|
||||
return base
|
||||
})
|
||||
const payload: QywxSendPayload = {}
|
||||
const trimmed = textContent.value.trim()
|
||||
if (trimmed) payload.text = { content: trimmed }
|
||||
if (pureAtts.length) payload.attachments = pureAtts
|
||||
|
||||
sending.value = true
|
||||
try {
|
||||
await emit('send', payload)
|
||||
textContent.value = ''
|
||||
attachments.value.forEach((a) => {
|
||||
if (a.preview?.startsWith('blob:')) URL.revokeObjectURL(a.preview)
|
||||
})
|
||||
attachments.value = []
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.send-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: #fafafa;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.attachment-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
|
||||
.attachment-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
background: #fff;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 4px;
|
||||
max-width: 220px;
|
||||
|
||||
.attachment-preview {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.attachment-filename {
|
||||
max-width: 140px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.attachment-remove {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.send-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.send-hint {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.link-picurl-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mini-preview {
|
||||
margin-top: 8px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #f5f5f5;
|
||||
|
||||
.el-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user