feat(chat): ChatTimeline 导出按钮 + 修复 downloadBlob 文件名丢失(Content-Disposition)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -42,16 +42,17 @@ export function getJson<T>(url: string, params?: Record<string, unknown>): Promi
|
|||||||
return http.get(url, { params }) as unknown as Promise<T>
|
return http.get(url, { params }) as unknown as Promise<T>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 以 blob 下载(用于 HTML 导出等二进制/文本附件下载)。返回固定文件名(响应拦截器丢失 content-disposition 头)。 */
|
/** 以 blob 下载(HTML 导出等附件)。从 Content-Disposition 取文件名(绕开 http 的 (r)=>r.data 解包拦截器)。 */
|
||||||
export async function downloadBlob(
|
export async function downloadBlob(
|
||||||
url: string,
|
url: string,
|
||||||
params?: Record<string, unknown>,
|
params?: Record<string, unknown>,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const res = await http.get(url, { params, responseType: 'blob' })
|
// 裸 axios(补 /api 前缀)拿全响应(含 headers),不经 http 拦截器
|
||||||
const blob = res as unknown as Blob
|
const res = await axios.get(`/api${url}`, { params, responseType: 'blob' })
|
||||||
// 从响应头取文件名(拦截器只解包 body,需用 raw 拦截;此处 res 已是 blob,header 丢失 → 用固定名)
|
const cd = (res.headers['content-disposition'] as string) || ''
|
||||||
const filename = 'chat-export.html'
|
const m = /filename="?([^";]+)"?/i.exec(cd)
|
||||||
const objUrl = URL.createObjectURL(blob)
|
const filename = m ? m[1] : 'chat-export.html'
|
||||||
|
const objUrl = URL.createObjectURL(res.data as Blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = objUrl
|
a.href = objUrl
|
||||||
a.download = filename
|
a.download = filename
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { ArrowLeft } from '@element-plus/icons-vue'
|
import { ArrowLeft } from '@element-plus/icons-vue'
|
||||||
import { getMessages } from '@/api'
|
import { getMessages } from '@/api'
|
||||||
|
import { downloadBlob } from '@/api/client'
|
||||||
import type { Channel, Message } from '@/api/types'
|
import type { Channel, Message } from '@/api/types'
|
||||||
import MessageBubble from '@/components/MessageBubble.vue'
|
import MessageBubble from '@/components/MessageBubble.vue'
|
||||||
import ChatSidebar from '@/components/ChatSidebar.vue'
|
import ChatSidebar from '@/components/ChatSidebar.vue'
|
||||||
@@ -228,6 +229,23 @@ function onSelectSession(s: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 导出当前会话/时段聊天记录为自包含 HTML(后端渲染气泡 + 附件内联)。 */
|
||||||
|
async function exportCurrent(): Promise<void> {
|
||||||
|
if (!hasActive.value) return
|
||||||
|
try {
|
||||||
|
await downloadBlob('/export/chat-html', {
|
||||||
|
channel: channelTyped.value,
|
||||||
|
owner_account_id: props.ownerId,
|
||||||
|
talker_id: isGroup.value ? undefined : props.talkerId,
|
||||||
|
room_id: isGroup.value ? props.roomId : undefined,
|
||||||
|
start: winStart.value,
|
||||||
|
end: winEnd.value,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(`导出失败:${(e as Error).message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// All-fields drawer state for the message detail view.
|
// All-fields drawer state for the message detail view.
|
||||||
const drawerOpen = ref(false)
|
const drawerOpen = ref(false)
|
||||||
const drawerMessage = ref<Message | null>(null)
|
const drawerMessage = ref<Message | null>(null)
|
||||||
@@ -297,6 +315,7 @@ onMounted(loadInitial)
|
|||||||
clearable
|
clearable
|
||||||
style="width: 200px"
|
style="width: 200px"
|
||||||
/>
|
/>
|
||||||
|
<el-button v-if="hasActive" size="small" type="primary" plain @click="exportCurrent">导出</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user