This commit is contained in:
Your Name
2026-04-21 09:49:44 +08:00
parent c9e0419895
commit c15c348b7a
454 changed files with 5391 additions and 25651 deletions
@@ -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