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
+184
View File
@@ -0,0 +1,184 @@
import request from '@/utils/request'
/** 企业微信 员工↔客户 消息收发(企业群发 + 会话内容存档) */
/** 会话列表(按最近消息时间倒序) */
export interface QywxMsgSession {
id: number
staff_userid: string
external_userid: string
roomid: string
session_type: number // 1=单聊 2=群聊
last_msg_id: string
last_msg_seq: number
last_msg_time: number
last_msg_type: string
last_msg_summary: string
unread_staff: number
create_time: number
update_time: number
customer?: {
name?: string
avatar?: string
type?: number
gender?: number
corp_name?: string
unionid?: string
} | null
staff?: {
id?: number
name?: string
avatar?: string
} | null
}
export interface QywxMsgArchiveItem {
id: number
msgid: string
seq: number
action: 'send' | 'recall' | 'switch'
from_user: string
from_is_staff: boolean
to_list: string[]
roomid: string
msgtype: string
content: string
media_id: string
md5sum: string
file_name: string
file_size: number
file_ext: string
play_length: number
send_time: number
from_profile?: Record<string, any> | null
media?: Array<{
id: number
sdkfileid: string
status: number
file_path: string
file_url: string
file_size: number
}>
}
export interface QywxSendTask {
id: number
admin_id: number
sender_userid: string
external_userids: string[]
chat_type: number
msg_payload: any
msg_template_id: string
fail_list: any[]
status: number
error: string
create_time: number
update_time: number
}
export interface QywxMsgAttachment {
msgtype: 'image' | 'video' | 'file' | 'link' | 'miniprogram'
image?: { media_id: string }
video?: { media_id: string }
file?: { media_id: string }
link?: { title: string; picurl?: string; desc?: string; url: string }
miniprogram?: { title: string; pic_media_id: string; appid: string; page: string }
}
export interface QywxSendPayload {
text?: { content: string }
attachments?: QywxMsgAttachment[]
}
/** 会话列表 */
export function qywxMsgSessionLists(params: {
page_no?: number
page_size?: number
admin_id?: number
staff_userid?: string
external_userid?: string
keyword?: string
only_unread?: 0 | 1
}) {
return request.get({ url: '/qywx.message/session_list', params })
}
/** 单会话消息历史 */
export function qywxMsgArchiveLists(params: {
page_no?: number
page_size?: number
session_id?: number
staff_userid?: string
external_userid?: string
roomid?: string
before_time?: number
after_time?: number
}) {
return request.get({ url: '/qywx.message/archive_list', params })
}
/** 清零会话未读 */
export function qywxMsgMarkRead(session_id: number) {
return request.post({ url: '/qywx.message/mark_read', params: { session_id } })
}
/** 创建企业群发任务(员工代发) */
export function qywxMsgSend(params: {
sender_userid: string
external_userids: string[]
msg_payload: QywxSendPayload
chat_type?: 'single' | 'group'
}) {
return request.post({ url: '/qywx.message/send', params })
}
/** 群发任务列表 */
export function qywxMsgSendTaskLists(params: {
page_no?: number
page_size?: number
sender_userid?: string
status?: number
}) {
return request.get({ url: '/qywx.message/send_task_list', params })
}
/** 群发任务送达详情 */
export function qywxMsgSendTaskDetail(task_id: number, cursor = '') {
return request.get({ url: '/qywx.message/send_task_detail', params: { task_id, cursor } })
}
/** 可代发员工(已绑定企微 userid) */
export function qywxMsgStaffList(keyword = '') {
return request.get({ url: '/qywx.message/staff_list', params: { keyword } })
}
/** 某员工的客户 */
export function qywxMsgCustomerOfStaff(params: {
staff_userid: string
keyword?: string
limit?: number
}) {
return request.get({ url: '/qywx.message/customer_of_staff', params })
}
/** 会话存档状态(SDK / 私钥诊断) */
export function qywxMsgArchiveStatus() {
return request.get({ url: '/qywx.message/archive_status' })
}
/** 手动触发一次会话存档拉取 */
export function qywxMsgPullArchive(max_batches = 5, download = false) {
return request.post({ url: '/qywx.message/pull_archive', params: { max_batches, download } })
}
/** 上传媒体文件到企微,返回 media_id(用于 attachments */
export async function qywxMsgUploadMedia(file: File, type: 'image' | 'voice' | 'video' | 'file') {
const fd = new FormData()
fd.append('file', file)
fd.append('type', type)
return request.post({
url: '/qywx.message/upload_to_qywx',
data: fd,
headers: { 'Content-Type': 'multipart/form-data' }
})
}
+4 -1
View File
@@ -307,7 +307,10 @@ export function prescriptionOrderLists(params: any) {
return request.get({ url: '/tcm.prescriptionOrder/lists', params })
}
/** 诊单下可关联的支付单(待支付/已支付的 zyt_order,已占用且未撤回的会排除;编辑时传 prescription_order_id 保留当前单已选) */
/**
* 诊单下可关联的支付单(已支付 zyt_order;已占用且未撤回的会排除;编辑时传 prescription_order_id 保留当前单已选)。
* 服务端仅返回创建时间在 2026-04-20(含)之后的支付单;编辑时本单已关联的旧单仍会出现在列表中。
*/
export function prescriptionOrderPaidPayOrders(params: {
diagnosis_id: number
prescription_order_id?: number
@@ -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
@@ -1544,7 +1544,9 @@ async function loadCreateOrderPaidOrders() {
}
try {
createOrderPaidOrdersLoading.value = true
const res: any = await prescriptionOrderPaidPayOrders({ diagnosis_id: did })
const res: any = await prescriptionOrderPaidPayOrders({
diagnosis_id: did
})
const lists = res?.lists ?? res?.data?.lists ?? []
createOrderPaidOrders.value = Array.isArray(lists) ? lists : []
const dep = Number(res?.deposit_min_amount ?? res?.data?.deposit_min_amount) || 0
@@ -444,10 +444,10 @@
<el-descriptions-item label="处方日期">{{ detailPrescription.prescription_date || '—' }}</el-descriptions-item>
<el-descriptions-item label="医师">{{ detailPrescription.doctor_name || '—' }}</el-descriptions-item>
<el-descriptions-item label="类型">{{ detailPrescription.prescription_type || '—' }}</el-descriptions-item>
<el-descriptions-item label="临床诊断" :span="2">
<el-descriptions-item label="临床诊断" :span="2" >
{{ detailPrescription.clinical_diagnosis || '—' }}
</el-descriptions-item>
<el-descriptions-item label="剂数 / 用法" :span="2">
<el-descriptions-item label="剂数 / 用法" :span="2" v-if="showPrescriptionAuditFilter">
{{ detailData.dose_count ?? detailPrescription.dose_count ?? '' }} {{ detailData.dose_unit || '' }} ·
{{ detailPrescription.usage_instruction || detailPrescription.usage_method || '' }}
</el-descriptions-item>
@@ -1357,35 +1357,60 @@
<!-- 处方详情查看处方单样式 -->
<el-drawer
v-model="prescriptionViewVisible"
title="查看处方"
size="920px"
:close-on-click-modal="false"
>
<template #header>
<div class="flex items-center justify-between w-full pr-2 gap-3">
<span class="text-base font-medium text-gray-800">查看处方</span>
<div v-if="prescriptionViewData && !prescriptionViewLoading" class="flex items-center gap-2 shrink-0">
<el-button
size="small"
:loading="prescriptionSlipExporting"
@click="downloadPrescriptionSlipImage"
>
下载图片
</el-button>
<el-button
size="small"
:loading="prescriptionSlipExporting"
@click="downloadPrescriptionSlipPdf"
>
下载PDF
</el-button>
</div>
</div>
</template>
<div v-loading="prescriptionViewLoading" class="cf-slip-wrap">
<div v-if="prescriptionViewData" class="cf-slip-print">
<div v-if="prescriptionViewData" ref="prescriptionSlipPrintRef" class="cf-slip-print">
<div class="cf-slip-header">
<h2 class="cf-slip-title">{{ SLIP_HOSPITAL_NAME }}处方笺</h2>
<div class="cf-slip-type">
<el-tag v-if="Number(prescriptionViewData.void_status) === 1" type="danger" size="small">已作废</el-tag>
<el-tag
v-if="Number(prescriptionViewData.void_status) === 1"
type="danger"
size="small"
class="cf-slip-no-export"
>已作废</el-tag>
<el-tag
v-else-if="Number(prescriptionViewData.business_prescription_audit_rejected) === 1"
type="danger"
size="small"
class="mr-1"
class="mr-1 cf-slip-no-export"
>已驳回</el-tag>
<el-tag
v-else-if="Number(prescriptionViewData.audit_status) === 1"
type="success"
size="small"
class="mr-1"
class="mr-1 cf-slip-no-export"
>已通过</el-tag>
<el-tag
v-else-if="Number(prescriptionViewData.audit_status) === 0"
type="warning"
size="small"
class="mr-1"
class="mr-1 cf-slip-no-export"
>待审核</el-tag>
<el-tag v-else type="danger" size="small" class="mr-1">已驳回</el-tag>
<el-tag v-else type="danger" size="small" class="mr-1 cf-slip-no-export">已驳回</el-tag>
<template v-if="Number(prescriptionViewData.void_status) !== 1">
<div>普通处方</div>
<div class="text-sm">当日有效</div>
@@ -1397,7 +1422,7 @@
</div>
<div
v-else-if="Number(prescriptionViewData.business_prescription_audit_rejected) === 1"
class="rounded border border-red-200 bg-red-50 text-red-800 text-sm px-3 py-2 mb-3"
class="cf-slip-no-export rounded border border-red-200 bg-red-50 text-red-800 text-sm px-3 py-2 mb-3"
>
<div class="font-medium">审核状态已驳回业务订单·处方审核</div>
<div v-if="prescriptionViewData.business_prescription_audit_remark" class="mt-1">
@@ -1407,7 +1432,7 @@
</div>
<div
v-else-if="Number(prescriptionViewData.audit_status) === 2"
class="rounded border border-red-200 bg-red-50 text-red-800 text-sm px-3 py-2 mb-3"
class="cf-slip-no-export rounded border border-red-200 bg-red-50 text-red-800 text-sm px-3 py-2 mb-3"
>
<div class="font-medium">消费者处方审核已驳回</div>
<div v-if="prescriptionViewData.audit_remark" class="mt-1">驳回意见{{ prescriptionViewData.audit_remark }}</div>
@@ -1451,6 +1476,9 @@
<span class="cf-slip-inline-gap" />
服用方式: {{ prescriptionViewData.usage_way ? ' ' + prescriptionViewData.usage_way : '' }}
</div>
<div v-if="slipChuwanVisible">
出丸{{ slipPillGrams === null ? '' : slipPillGrams }}
</div>
<div v-if="slipDietaryText" class="cf-slip-dietary">忌口{{ slipDietaryText }}</div>
<div v-if="prescriptionViewData.usage_notes" class="cf-slip-notes">{{ prescriptionViewData.usage_notes }}</div>
</div>
@@ -1642,6 +1670,8 @@ import {
prescriptionOrderPreviewGancaoRecipel,
prescriptionDetail
} from '@/api/tcm'
import html2canvas from 'html2canvas'
import { jsPDF } from 'jspdf'
import { getDictData } from '@/api/app'
import { usePaging } from '@/hooks/usePaging'
import feedback from '@/utils/feedback'
@@ -1926,10 +1956,19 @@ function formatTime(v: unknown) {
return String(v)
}
function canEditRow(row: { fulfillment_status?: number }) {
function canEditRow(row: {
fulfillment_status?: number
gancao_reciperl_order_no?: string | null
gancao_submit_time?: number | null
}) {
// 已发货(5)、已签收(6)、已完成(3)、已取消(4) 不可编辑
const fs = Number(row.fulfillment_status)
return fs === 1 || fs === 2
if (fs !== 1 && fs !== 2) return false
// 已成功提交甘草(已生成甘草处方单号 或 提交时间 > 0)不允许再编辑
const gcNo = String(row.gancao_reciperl_order_no || '').trim()
const gcTime = Number(row.gancao_submit_time || 0)
if (gcNo !== '' || gcTime > 0) return false
return true
}
function canRxAudit(row: { prescription_audit_status?: number; fulfillment_status?: number }) {
@@ -2097,7 +2136,7 @@ async function loadDetailUnlinkedPayOrders(diagnosisId: number, prescriptionOrde
prescription_order_id: prescriptionOrderId
})
const lists = res?.lists ?? res?.data?.lists ?? []
// 过滤掉已关联的支付单
// 列表已由服务端限制为 2026-04-20 及之后创建的支付单(本单已关联除外);此处再过滤已关联项
const filtered = Array.isArray(lists)
? lists.filter((item: any) => !linkedIds.includes(item.id))
: []
@@ -2386,6 +2425,7 @@ async function loadEditPaidOrders(diagnosisId: number, prescriptionOrderId?: num
...(prescriptionOrderId ? { prescription_order_id: prescriptionOrderId } : {})
})
const lists = res?.lists ?? res?.data?.lists ?? []
// 可选支付单:服务端仅 2026-04-20 及之后;编辑时含本单已关联的旧单
editPaidOrders.value = Array.isArray(lists) ? lists : []
const dep = Number(res?.deposit_min_amount ?? res?.data?.deposit_min_amount)
if (!Number.isNaN(dep) && dep >= 0) {
@@ -2993,7 +3033,7 @@ async function loadAddPayOrderAvailable(diagnosisId: number, prescriptionOrderId
prescription_order_id: prescriptionOrderId
})
const lists = res?.lists ?? res?.data?.lists ?? []
// 过滤掉当前订单已关联的支付单
// 服务端仅 2026-04-20 及之后;再排除当前订单已关联
const filtered = Array.isArray(lists)
? lists.filter((item: any) => !currentLinkedIds.includes(item.id))
: []
@@ -3155,6 +3195,8 @@ const SLIP_ADDRESS_LINE = '地址:四川省成都市双流区黄甲街道黄
const prescriptionViewVisible = ref(false)
const prescriptionViewLoading = ref(false)
const prescriptionViewData = ref<any>(null)
const prescriptionSlipPrintRef = ref<HTMLElement | null>(null)
const prescriptionSlipExporting = ref(false)
// 处方单辅助函数
const slipHerbsList = computed(() => {
@@ -3169,6 +3211,23 @@ const slipDietaryText = computed(() => {
return ''
})
const slipChuwanVisible = computed(() => {
const d = prescriptionViewData.value
if (!d) return false
return (d.prescription_type || '浓缩水丸') === '浓缩水丸'
})
const slipPillGrams = computed(() => {
const d = prescriptionViewData.value
if (!d || (d.prescription_type || '浓缩水丸') !== '浓缩水丸') return null
const days = Number(d.usage_days ?? d.dose_count)
const times = Number(d.times_per_day)
const doseG = Number(d.dosage_amount)
if (!Number.isFinite(days) || !Number.isFinite(times) || !Number.isFinite(doseG)) return null
if (days <= 0 || times <= 0 || doseG <= 0) return null
return days * times * doseG
})
function formatAuditTime(ts: number | null | undefined) {
if (!ts) return ''
const d = new Date(ts * 1000)
@@ -3215,6 +3274,93 @@ async function openPrescriptionView(row: any) {
prescriptionViewLoading.value = false
}
}
async function preloadSlipImagesToDataUrl(el: HTMLElement): Promise<void> {
const imgs = el.querySelectorAll<HTMLImageElement>('img[src^="http"]')
const tasks = Array.from(imgs).map(async (img) => {
try {
const res = await fetch(img.src, { mode: 'cors' })
if (!res.ok) return
const blob = await res.blob()
const dataUrl = await new Promise<string>((resolve, reject) => {
const r = new FileReader()
r.onload = () => resolve(r.result as string)
r.onerror = reject
r.readAsDataURL(blob)
})
img.src = dataUrl
} catch {
/* 跨域失败时保留原 src */
}
})
await Promise.allSettled(tasks)
}
async function capturePrescriptionSlipCanvas(): Promise<HTMLCanvasElement> {
await nextTick()
const el = prescriptionSlipPrintRef.value
if (!el) throw new Error('未找到处方笺内容')
el.scrollIntoView({ behavior: 'instant', block: 'nearest' })
await preloadSlipImagesToDataUrl(el)
await new Promise((r) => setTimeout(r, 200))
return html2canvas(el, {
scale: 2,
useCORS: true,
logging: false,
backgroundColor: '#ffffff',
ignoreElements: (node) =>
node instanceof HTMLElement && node.classList.contains('cf-slip-no-export')
})
}
function slipDownloadBaseName() {
const name = prescriptionViewData.value?.patient_name
const safe = typeof name === 'string' && name.trim() ? name.trim().replace(/[\\/:*?"<>|]/g, '_') : '未命名'
return `处方_${safe}_${Date.now()}`
}
async function downloadPrescriptionSlipImage() {
if (!prescriptionViewData.value) return
prescriptionSlipExporting.value = true
try {
const canvas = await capturePrescriptionSlipCanvas()
const link = document.createElement('a')
link.download = `${slipDownloadBaseName()}.png`
link.href = canvas.toDataURL('image/png')
link.click()
feedback.msgSuccess('图片已下载')
} catch {
feedback.msgError('下载图片失败')
} finally {
prescriptionSlipExporting.value = false
}
}
async function downloadPrescriptionSlipPdf() {
if (!prescriptionViewData.value) return
prescriptionSlipExporting.value = true
try {
const canvas = await capturePrescriptionSlipCanvas()
const imgData = canvas.toDataURL('image/png')
const pdf = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a5'
})
const pageW = pdf.internal.pageSize.getWidth()
const pageH = pdf.internal.pageSize.getHeight()
const scale = Math.min(pageW / canvas.width, pageH / canvas.height)
const imgW = canvas.width * scale
const imgH = canvas.height * scale
pdf.addImage(imgData, 'PNG', 0, 0, imgW, imgH)
pdf.save(`${slipDownloadBaseName()}.pdf`)
feedback.msgSuccess('PDF已下载')
} catch {
feedback.msgError('下载PDF失败')
} finally {
prescriptionSlipExporting.value = false
}
}
</script>
<style scoped lang="scss">