655 lines
20 KiB
Vue
655 lines
20 KiB
Vue
|
|
<template>
|
|||
|
|
<slot></slot>
|
|||
|
|
<!-- 全局右下角悬浮按钮(参考 AIRBT.html 样式);登录页不展示 -->
|
|||
|
|
<div
|
|||
|
|
v-if="showAssistantFab"
|
|||
|
|
ref="fabRef"
|
|||
|
|
class="ai-assistant-fab"
|
|||
|
|
:class="{ 'is-loading': pageLoading, 'is-hidden': dialogVisible, 'is-dragging': fabDragging }"
|
|||
|
|
:style="fabPositionStyle"
|
|||
|
|
@pointerdown="onFabPointerDown"
|
|||
|
|
>
|
|||
|
|
<div class="fab-icon-wrap">
|
|||
|
|
<Icon v-if="!pageLoading" icon="ep:chat-dot-round" class="fab-icon" />
|
|||
|
|
<Icon v-else icon="ep:loading" class="fab-icon fab-loading" />
|
|||
|
|
</div>
|
|||
|
|
<span class="fab-text">AI 决策助手</span>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<AiImageChatDialog
|
|||
|
|
v-model="dialogVisible"
|
|||
|
|
:initial-payload="initialPayload"
|
|||
|
|
/>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
import html2canvas from 'html2canvas'
|
|||
|
|
import { Icon } from '@/components/Icon'
|
|||
|
|
import AiImageChatDialog from './AiImageChatDialog.vue'
|
|||
|
|
import {
|
|||
|
|
AI_ASSISTANT_KEY,
|
|||
|
|
AI_CHAT_MESSAGES_KEY,
|
|||
|
|
AI_ASSISTANT_REPORT_CONTEXT_KEY,
|
|||
|
|
type AiAssistantReportContext
|
|||
|
|
} from './useAiAssistant'
|
|||
|
|
import { ElMessage } from 'element-plus'
|
|||
|
|
import { useRoute } from 'vue-router'
|
|||
|
|
|
|||
|
|
defineOptions({ name: 'AiAssistantProvider' })
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* html2canvas@1.x 无法解析 CSS Color 4/5 的 color()、lab()、oklch() 等,会在解析阶段抛错。
|
|||
|
|
* 在克隆 DOM 上用安全色覆盖(仅影响克隆树,不影响真实页面)。
|
|||
|
|
* 需处理截图根节点及其子树,以及 html/body 祖先链(CSS 变量常挂在根上)。
|
|||
|
|
*/
|
|||
|
|
function sanitizeClonedDomForHtml2Canvas(clonedDoc: Document, clonedRoot: HTMLElement) {
|
|||
|
|
const win = clonedDoc.defaultView
|
|||
|
|
if (!win) return
|
|||
|
|
|
|||
|
|
const unsafe = (v: string) =>
|
|||
|
|
typeof v === 'string' && /\b(color|lab|lch|oklch|oklab|hwb)\(/i.test(v)
|
|||
|
|
|
|||
|
|
const patchOne = (el: Element) => {
|
|||
|
|
try {
|
|||
|
|
if (el instanceof HTMLElement) {
|
|||
|
|
const cs = win.getComputedStyle(el)
|
|||
|
|
const keys = [
|
|||
|
|
'color',
|
|||
|
|
'background-color',
|
|||
|
|
'border-top-color',
|
|||
|
|
'border-right-color',
|
|||
|
|
'border-bottom-color',
|
|||
|
|
'border-left-color',
|
|||
|
|
'outline-color',
|
|||
|
|
'column-rule-color',
|
|||
|
|
'caret-color'
|
|||
|
|
] as const
|
|||
|
|
for (const key of keys) {
|
|||
|
|
const val = cs.getPropertyValue(key)
|
|||
|
|
if (val && unsafe(val)) {
|
|||
|
|
el.style.setProperty(key, key === 'color' ? '#303133' : '#ebeef5', 'important')
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
const bg = cs.getPropertyValue('background')
|
|||
|
|
if (bg && unsafe(bg)) {
|
|||
|
|
el.style.setProperty('background', 'none', 'important')
|
|||
|
|
el.style.setProperty('background-color', '#ffffff', 'important')
|
|||
|
|
}
|
|||
|
|
const bgImg = cs.getPropertyValue('background-image')
|
|||
|
|
if (bgImg && unsafe(bgImg)) {
|
|||
|
|
el.style.setProperty('background-image', 'none', 'important')
|
|||
|
|
const bgc = cs.getPropertyValue('background-color')
|
|||
|
|
if (!bgc || unsafe(bgc)) {
|
|||
|
|
el.style.setProperty('background-color', '#fafafa', 'important')
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
const bs = cs.getPropertyValue('box-shadow')
|
|||
|
|
if (bs && unsafe(bs)) {
|
|||
|
|
el.style.setProperty('box-shadow', 'none', 'important')
|
|||
|
|
}
|
|||
|
|
const ts = cs.getPropertyValue('text-shadow')
|
|||
|
|
if (ts && unsafe(ts)) {
|
|||
|
|
el.style.setProperty('text-shadow', 'none', 'important')
|
|||
|
|
}
|
|||
|
|
const flt = cs.getPropertyValue('filter')
|
|||
|
|
if (flt && unsafe(flt)) {
|
|||
|
|
el.style.setProperty('filter', 'none', 'important')
|
|||
|
|
}
|
|||
|
|
// 内联 style 里含 color()/lab() 等时尝试替换,仍无法解析则去掉内联避免拖垮 html2canvas
|
|||
|
|
const inline = el.getAttribute('style')
|
|||
|
|
if (inline && unsafe(inline)) {
|
|||
|
|
let s = inline
|
|||
|
|
.replace(/\bcolor\([^)]*\)/gi, 'rgb(48,49,51)')
|
|||
|
|
.replace(/\b(?:lab|lch|oklch|oklab|hwb)\([^)]*\)/gi, 'rgb(200,200,200)')
|
|||
|
|
if (unsafe(s)) {
|
|||
|
|
el.removeAttribute('style')
|
|||
|
|
} else {
|
|||
|
|
el.setAttribute('style', s)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} else if (el instanceof SVGElement) {
|
|||
|
|
const cs = win.getComputedStyle(el)
|
|||
|
|
;['fill', 'stroke'].forEach((attr) => {
|
|||
|
|
const val = cs.getPropertyValue(attr) || el.getAttribute(attr) || ''
|
|||
|
|
if (unsafe(val)) {
|
|||
|
|
el.setAttribute(attr, attr === 'fill' ? '#409eff' : '#303133')
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
/* 单节点忽略 */
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let ancestor: HTMLElement | null = clonedRoot
|
|||
|
|
while (ancestor) {
|
|||
|
|
patchOne(ancestor)
|
|||
|
|
ancestor = ancestor.parentElement
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const walk = (el: Element) => {
|
|||
|
|
patchOne(el)
|
|||
|
|
for (const child of Array.from(el.children)) {
|
|||
|
|
walk(child)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
walk(clonedRoot)
|
|||
|
|
|
|||
|
|
// 覆盖 :root 上可能含 color() 的主题变量(html2canvas 会解析到并报错)
|
|||
|
|
if (clonedDoc.head && !clonedDoc.getElementById('html2canvas-el-vars-override')) {
|
|||
|
|
const inj = clonedDoc.createElement('style')
|
|||
|
|
inj.id = 'html2canvas-el-vars-override'
|
|||
|
|
inj.textContent = `
|
|||
|
|
:root, html, body {
|
|||
|
|
--el-color-primary: #409eff !important;
|
|||
|
|
--el-color-primary-light-3: #79bbff !important;
|
|||
|
|
--el-color-primary-light-5: #a0cfff !important;
|
|||
|
|
--el-color-primary-light-7: #c6e2ff !important;
|
|||
|
|
--el-color-primary-light-8: #d9ecff !important;
|
|||
|
|
--el-color-primary-light-9: #ecf5ff !important;
|
|||
|
|
--el-color-primary-dark-2: #337ecc !important;
|
|||
|
|
--el-color-success: #67c23a !important;
|
|||
|
|
--el-color-warning: #e6a23c !important;
|
|||
|
|
--el-color-danger: #f56c6c !important;
|
|||
|
|
--el-color-info: #909399 !important;
|
|||
|
|
--el-text-color-primary: #303133 !important;
|
|||
|
|
--el-text-color-regular: #606266 !important;
|
|||
|
|
--el-text-color-secondary: #909399 !important;
|
|||
|
|
--el-text-color-placeholder: #a8abb2 !important;
|
|||
|
|
--el-text-color-disabled: #c0c4cc !important;
|
|||
|
|
--el-border-color: #dcdfe6 !important;
|
|||
|
|
--el-border-color-light: #e4e7ed !important;
|
|||
|
|
--el-border-color-lighter: #ebeef5 !important;
|
|||
|
|
--el-border-color-extra-light: #f2f6fc !important;
|
|||
|
|
--el-fill-color: #f0f2f5 !important;
|
|||
|
|
--el-fill-color-light: #f5f7fa !important;
|
|||
|
|
--el-fill-color-lighter: #fafafa !important;
|
|||
|
|
--el-fill-color-blank: #ffffff !important;
|
|||
|
|
--el-bg-color: #ffffff !important;
|
|||
|
|
--el-bg-color-page: #f2f3f5 !important;
|
|||
|
|
}
|
|||
|
|
`
|
|||
|
|
clonedDoc.head.appendChild(inj)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function runHtml2CanvasOnClone(clonedDoc: Document, clonedNode: HTMLElement, transparentPixel: string) {
|
|||
|
|
sanitizeClonedDomForHtml2Canvas(clonedDoc, clonedNode)
|
|||
|
|
const imgs = clonedNode.querySelectorAll('img')
|
|||
|
|
imgs.forEach((img) => {
|
|||
|
|
const src = img.src || img.getAttribute('src')
|
|||
|
|
if (!src || src.startsWith('data:') || src.startsWith('blob:')) return
|
|||
|
|
try {
|
|||
|
|
if (new URL(src, window.location.href).origin !== window.location.origin) {
|
|||
|
|
const w = img.width || img.offsetWidth || (img as HTMLImageElement).naturalWidth
|
|||
|
|
const h = img.height || img.offsetHeight || (img as HTMLImageElement).naturalHeight
|
|||
|
|
img.src = transparentPixel
|
|||
|
|
img.removeAttribute('crossorigin')
|
|||
|
|
if (w && h) {
|
|||
|
|
img.setAttribute('width', String(w))
|
|||
|
|
img.setAttribute('height', String(h))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
/* ignore */
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 登录 / SSO / 社交登录页不显示 AI 悬浮入口(兼容子路径) */
|
|||
|
|
function isHideAssistantFabPath(path: string): boolean {
|
|||
|
|
const p = path || ''
|
|||
|
|
if (p === '/login' || p === '/sso' || p === '/social-login') return true
|
|||
|
|
if (p.startsWith('/login/') || p.startsWith('/sso/')) return true
|
|||
|
|
if (p.startsWith('/social-login')) return true
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 仅由业务页通过 setAiAssistantFabEnabled(true) 开启;默认关闭,避免全站都出现悬浮入口 */
|
|||
|
|
const fabEnabled = ref(false)
|
|||
|
|
function setAiAssistantFabEnabled(enabled: boolean) {
|
|||
|
|
fabEnabled.value = enabled
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const route = useRoute()
|
|||
|
|
const showAssistantFab = computed(
|
|||
|
|
() => !isHideAssistantFabPath(route.path) && fabEnabled.value
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
/** 悬浮按钮位置(可拖拽),持久化到 localStorage */
|
|||
|
|
const FAB_POS_STORAGE = 'ai-assistant-fab-position'
|
|||
|
|
const fabRef = ref<HTMLElement | null>(null)
|
|||
|
|
const fabPos = ref<{ left: number; top: number } | null>(null)
|
|||
|
|
const fabDragging = ref(false)
|
|||
|
|
let fabDragSession: {
|
|||
|
|
startX: number
|
|||
|
|
startY: number
|
|||
|
|
origLeft: number
|
|||
|
|
origTop: number
|
|||
|
|
moved: boolean
|
|||
|
|
} | null = null
|
|||
|
|
|
|||
|
|
const fabPositionStyle = computed(() => {
|
|||
|
|
if (!fabPos.value) return {}
|
|||
|
|
return {
|
|||
|
|
left: `${fabPos.value.left}px`,
|
|||
|
|
top: `${fabPos.value.top}px`,
|
|||
|
|
right: 'auto',
|
|||
|
|
bottom: 'auto'
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
function clampFabToViewport() {
|
|||
|
|
const el = fabRef.value
|
|||
|
|
if (!el) return
|
|||
|
|
const pad = 8
|
|||
|
|
const w = el.offsetWidth || 200
|
|||
|
|
const h = el.offsetHeight || 48
|
|||
|
|
const vw = window.innerWidth
|
|||
|
|
const vh = window.innerHeight
|
|||
|
|
if (!fabPos.value) return
|
|||
|
|
let { left, top } = fabPos.value
|
|||
|
|
// 持久化坐标在视窗外(换屏、缩放、误拖)时恢复默认右下角
|
|||
|
|
if (left + w < pad || top + h < pad || left > vw - pad || top > vh - pad) {
|
|||
|
|
fabPos.value = null
|
|||
|
|
try {
|
|||
|
|
localStorage.removeItem(FAB_POS_STORAGE)
|
|||
|
|
} catch {
|
|||
|
|
/* ignore */
|
|||
|
|
}
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
const nl = Math.max(pad, Math.min(left, vw - w - pad))
|
|||
|
|
const nt = Math.max(pad, Math.min(top, vh - h - pad))
|
|||
|
|
if (nl !== left || nt !== top) {
|
|||
|
|
fabPos.value = { left: nl, top: nt }
|
|||
|
|
try {
|
|||
|
|
localStorage.setItem(FAB_POS_STORAGE, JSON.stringify(fabPos.value))
|
|||
|
|
} catch {
|
|||
|
|
/* ignore */
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onFabResizeClamp() {
|
|||
|
|
clampFabToViewport()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
onMounted(() => {
|
|||
|
|
try {
|
|||
|
|
const raw = localStorage.getItem(FAB_POS_STORAGE)
|
|||
|
|
if (raw) {
|
|||
|
|
const p = JSON.parse(raw) as { left: number; top: number }
|
|||
|
|
if (typeof p.left === 'number' && typeof p.top === 'number' && Number.isFinite(p.left) && Number.isFinite(p.top)) {
|
|||
|
|
fabPos.value = p
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
/* ignore */
|
|||
|
|
}
|
|||
|
|
nextTick(() => {
|
|||
|
|
clampFabToViewport()
|
|||
|
|
})
|
|||
|
|
window.addEventListener('resize', onFabResizeClamp)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
onUnmounted(() => {
|
|||
|
|
window.removeEventListener('resize', onFabResizeClamp)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
function onFabPointerMove(e: PointerEvent) {
|
|||
|
|
if (!fabDragSession) return
|
|||
|
|
const dx = e.clientX - fabDragSession.startX
|
|||
|
|
const dy = e.clientY - fabDragSession.startY
|
|||
|
|
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) fabDragSession.moved = true
|
|||
|
|
const el = fabRef.value
|
|||
|
|
if (!el) return
|
|||
|
|
let nl = fabDragSession.origLeft + dx
|
|||
|
|
let nt = fabDragSession.origTop + dy
|
|||
|
|
const pad = 8
|
|||
|
|
const w = el.offsetWidth
|
|||
|
|
const h = el.offsetHeight
|
|||
|
|
nl = Math.max(pad, Math.min(nl, window.innerWidth - w - pad))
|
|||
|
|
nt = Math.max(pad, Math.min(nt, window.innerHeight - h - pad))
|
|||
|
|
fabPos.value = { left: nl, top: nt }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onFabPointerUp(e: PointerEvent) {
|
|||
|
|
if (!fabDragSession) return
|
|||
|
|
const moved = fabDragSession.moved
|
|||
|
|
fabDragSession = null
|
|||
|
|
fabDragging.value = false
|
|||
|
|
const el = fabRef.value
|
|||
|
|
if (el) {
|
|||
|
|
try {
|
|||
|
|
el.releasePointerCapture(e.pointerId)
|
|||
|
|
} catch {
|
|||
|
|
/* ignore */
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
window.removeEventListener('pointermove', onFabPointerMove)
|
|||
|
|
window.removeEventListener('pointerup', onFabPointerUp)
|
|||
|
|
if (fabPos.value) {
|
|||
|
|
try {
|
|||
|
|
localStorage.setItem(FAB_POS_STORAGE, JSON.stringify(fabPos.value))
|
|||
|
|
} catch {
|
|||
|
|
/* ignore */
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (!moved) {
|
|||
|
|
void onFabClick()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onFabPointerDown(e: PointerEvent) {
|
|||
|
|
if (e.button !== 0) return
|
|||
|
|
if (pageLoading.value) return
|
|||
|
|
const el = fabRef.value
|
|||
|
|
if (!el) return
|
|||
|
|
const r = el.getBoundingClientRect()
|
|||
|
|
fabDragSession = {
|
|||
|
|
startX: e.clientX,
|
|||
|
|
startY: e.clientY,
|
|||
|
|
origLeft: r.left,
|
|||
|
|
origTop: r.top,
|
|||
|
|
moved: false
|
|||
|
|
}
|
|||
|
|
fabDragging.value = true
|
|||
|
|
el.setPointerCapture(e.pointerId)
|
|||
|
|
window.addEventListener('pointermove', onFabPointerMove)
|
|||
|
|
window.addEventListener('pointerup', onFabPointerUp)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const dialogVisible = ref(false)
|
|||
|
|
const initialPayload = ref<{
|
|||
|
|
file?: File | null
|
|||
|
|
query?: string
|
|||
|
|
autoSend?: boolean
|
|||
|
|
prompt?: string
|
|||
|
|
moduleName?: string
|
|||
|
|
moduleCode?: string
|
|||
|
|
pendingUserText?: string
|
|||
|
|
} | null>(null)
|
|||
|
|
const chatMessages = ref<import('./useAiAssistant').ChatMessage[]>([])
|
|||
|
|
provide(AI_CHAT_MESSAGES_KEY, chatMessages)
|
|||
|
|
|
|||
|
|
/** 报告单据上下文:模块名、模块编码、截图文件、截图目标 */
|
|||
|
|
const reportModuleName = ref<string | null>(null)
|
|||
|
|
const reportModuleCode = ref<string | null>(null)
|
|||
|
|
const reportScreenshotFile = ref<File | null>(null)
|
|||
|
|
const reportScreenshotTarget = ref<string | HTMLElement | null>(null)
|
|||
|
|
|
|||
|
|
/** 保存时自动截取当前组件页面(Y 轴全部内容),供报告单据使用 */
|
|||
|
|
async function captureScreenshotForReport(): Promise<File | null> {
|
|||
|
|
const targetRef = reportScreenshotTarget.value
|
|||
|
|
if (!targetRef) return null
|
|||
|
|
const target = typeof targetRef === 'string' ? document.querySelector(targetRef) : targetRef
|
|||
|
|
if (!target || !(target instanceof HTMLElement)) return null
|
|||
|
|
|
|||
|
|
const TRANSPARENT_PIXEL =
|
|||
|
|
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
|
|||
|
|
const opts = {
|
|||
|
|
useCORS: true,
|
|||
|
|
allowTaint: true,
|
|||
|
|
logging: false,
|
|||
|
|
ignoreElements: (node: Node) => {
|
|||
|
|
const n = node as HTMLElement
|
|||
|
|
return n.classList?.contains('ai-assistant-fab') === true
|
|||
|
|
},
|
|||
|
|
onclone: (clonedDoc: Document, clonedNode: HTMLElement) => {
|
|||
|
|
runHtml2CanvasOnClone(clonedDoc, clonedNode, TRANSPARENT_PIXEL)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const w = target.scrollWidth || target.offsetWidth
|
|||
|
|
const h = target.scrollHeight || target.offsetHeight
|
|||
|
|
const maxDim = Math.max(w, h)
|
|||
|
|
const scale = maxDim > 8192 ? 1 : Math.min(2, window.devicePixelRatio || 2)
|
|||
|
|
const canvas = await html2canvas(target, { ...opts, scale })
|
|||
|
|
return new Promise((resolve) => {
|
|||
|
|
canvas.toBlob(
|
|||
|
|
(blob) => {
|
|||
|
|
if (!blob) {
|
|||
|
|
resolve(null)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
resolve(new File([blob], 'screenshot.png', { type: 'image/png' }))
|
|||
|
|
},
|
|||
|
|
'image/png',
|
|||
|
|
0.9
|
|||
|
|
)
|
|||
|
|
})
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('报告截图失败:', e)
|
|||
|
|
return null
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
provide<AiAssistantReportContext>(AI_ASSISTANT_REPORT_CONTEXT_KEY, {
|
|||
|
|
moduleName: reportModuleName,
|
|||
|
|
moduleCode: reportModuleCode,
|
|||
|
|
screenshotFile: reportScreenshotFile,
|
|||
|
|
captureScreenshot: captureScreenshotForReport
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const pageLoading = ref(false)
|
|||
|
|
function setPageLoading(v: boolean) {
|
|||
|
|
pageLoading.value = v
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 当前页面的 AI 任务提示词,由页面通过 setPagePrompt 设置 */
|
|||
|
|
const pagePromptRef = ref<string | null>(null)
|
|||
|
|
function setPagePrompt(p: string | null) {
|
|||
|
|
pagePromptRef.value = p
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 当前页面的模块名称,由页面通过 setPageModuleName 设置,用于报告单据 */
|
|||
|
|
function setPageModuleName(name: string | null) {
|
|||
|
|
reportModuleName.value = name
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 当前页面的模块编码,由页面通过 setPageModuleCode 设置,用于报告单据 */
|
|||
|
|
function setPageModuleCode(code: string | null) {
|
|||
|
|
reportModuleCode.value = code
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 当前页面截图目标(选择器或元素),保存报告时自动截取该区域 Y 轴全部内容 */
|
|||
|
|
function setScreenshotTarget(target: string | HTMLElement | null) {
|
|||
|
|
reportScreenshotTarget.value = target
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 截图并打开对话框(供 AiAssistantMark 等调用) */
|
|||
|
|
async function openWithScreenshot(
|
|||
|
|
target: string | HTMLElement,
|
|||
|
|
options?: { moduleName?: string; moduleCode?: string; prompt?: string; pendingUserText?: string }
|
|||
|
|
) {
|
|||
|
|
if (pageLoading.value) return
|
|||
|
|
await doScreenshot(
|
|||
|
|
target,
|
|||
|
|
options?.prompt,
|
|||
|
|
options?.moduleName,
|
|||
|
|
options?.moduleCode,
|
|||
|
|
options?.pendingUserText
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 点击悬浮框:只打开对话框,不截图(仅 AI 分析入口需要截图) */
|
|||
|
|
async function onFabClick() {
|
|||
|
|
if (pageLoading.value) return
|
|||
|
|
const prompt = pagePromptRef.value || undefined
|
|||
|
|
const moduleName = reportModuleName.value || undefined
|
|||
|
|
const moduleCode = reportModuleCode.value || undefined
|
|||
|
|
initialPayload.value = { prompt, autoSend: false, moduleName, moduleCode }
|
|||
|
|
reportScreenshotFile.value = null
|
|||
|
|
dialogVisible.value = true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function doScreenshot(
|
|||
|
|
el: string | HTMLElement,
|
|||
|
|
prompt?: string,
|
|||
|
|
moduleName?: string,
|
|||
|
|
moduleCode?: string,
|
|||
|
|
pendingUserText?: string
|
|||
|
|
) {
|
|||
|
|
const target = typeof el === 'string' ? document.querySelector(el) : el
|
|||
|
|
if (!target || !(target instanceof HTMLElement)) {
|
|||
|
|
ElMessage.warning('未找到截图区域')
|
|||
|
|
dialogVisible.value = true
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
const payloadPrompt = prompt
|
|||
|
|
const payloadModuleName = moduleName
|
|||
|
|
const payloadModuleCode = moduleCode
|
|||
|
|
const payloadPendingUserText = pendingUserText
|
|||
|
|
|
|||
|
|
const TRANSPARENT_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
|
|||
|
|
|
|||
|
|
const opts = {
|
|||
|
|
useCORS: true,
|
|||
|
|
allowTaint: true,
|
|||
|
|
logging: false,
|
|||
|
|
ignoreElements: (node: Node) => {
|
|||
|
|
const n = node as HTMLElement
|
|||
|
|
return n.classList?.contains('ai-assistant-fab') === true
|
|||
|
|
},
|
|||
|
|
onclone: (clonedDoc: Document, clonedNode: HTMLElement) => {
|
|||
|
|
runHtml2CanvasOnClone(clonedDoc, clonedNode, TRANSPARENT_PIXEL)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const tryCapture = async (scale: number = 1): Promise<void> => {
|
|||
|
|
const canvas = await html2canvas(target, { ...opts, scale })
|
|||
|
|
return new Promise((resolve, reject) => {
|
|||
|
|
canvas.toBlob(
|
|||
|
|
(blob) => {
|
|||
|
|
if (!blob) {
|
|||
|
|
reject(new Error('toBlob失败'))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
const file = new File([blob], 'screenshot.png', { type: 'image/png' })
|
|||
|
|
initialPayload.value = {
|
|||
|
|
file,
|
|||
|
|
autoSend: false,
|
|||
|
|
prompt: payloadPrompt,
|
|||
|
|
moduleName: payloadModuleName,
|
|||
|
|
moduleCode: payloadModuleCode,
|
|||
|
|
pendingUserText: payloadPendingUserText
|
|||
|
|
}
|
|||
|
|
reportScreenshotFile.value = file
|
|||
|
|
if (payloadModuleCode) reportModuleCode.value = payloadModuleCode
|
|||
|
|
dialogVisible.value = true
|
|||
|
|
resolve()
|
|||
|
|
},
|
|||
|
|
'image/png',
|
|||
|
|
0.9
|
|||
|
|
)
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
// html2canvas 会截取完整可滚动内容,用 scroll 尺寸判断
|
|||
|
|
const w = target.scrollWidth || target.offsetWidth
|
|||
|
|
const h = target.scrollHeight || target.offsetHeight
|
|||
|
|
const maxDim = Math.max(w, h)
|
|||
|
|
// 浏览器 canvas 单边约 16384px,scale 2 时需 maxDim <= 8192
|
|||
|
|
const scale = maxDim > 8192 ? 1 : Math.min(2, window.devicePixelRatio || 2)
|
|||
|
|
await tryCapture(scale)
|
|||
|
|
} catch (e) {
|
|||
|
|
try {
|
|||
|
|
await tryCapture(1)
|
|||
|
|
} catch (e2) {
|
|||
|
|
console.error('截图失败:', e2)
|
|||
|
|
ElMessage.error('截图失败,已打开对话框')
|
|||
|
|
initialPayload.value = null
|
|||
|
|
dialogVisible.value = true
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
watch(dialogVisible, (v) => {
|
|||
|
|
if (!v) {
|
|||
|
|
initialPayload.value = null
|
|||
|
|
reportScreenshotFile.value = null
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
provide(AI_ASSISTANT_KEY, {
|
|||
|
|
openWithScreenshot,
|
|||
|
|
setPageLoading,
|
|||
|
|
setPagePrompt,
|
|||
|
|
setPageModuleName,
|
|||
|
|
setPageModuleCode,
|
|||
|
|
setScreenshotTarget,
|
|||
|
|
setAiAssistantFabEnabled
|
|||
|
|
})
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
/* 参考 AIRBT.html:白底、蓝边、圆角药丸、图标蓝圈、hover 放大 */
|
|||
|
|
.ai-assistant-fab {
|
|||
|
|
position: fixed;
|
|||
|
|
right: 24px;
|
|||
|
|
bottom: 85px;
|
|||
|
|
z-index: 10050;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
padding: 12px 20px;
|
|||
|
|
background: #fff;
|
|||
|
|
border: 1px solid #bfdbfe;
|
|||
|
|
border-radius: 9999px;
|
|||
|
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
|||
|
|
cursor: grab;
|
|||
|
|
touch-action: none;
|
|||
|
|
user-select: none;
|
|||
|
|
transition: transform 0.3s, box-shadow 0.3s, opacity 0.3s;
|
|||
|
|
}
|
|||
|
|
.ai-assistant-fab.is-dragging {
|
|||
|
|
cursor: grabbing;
|
|||
|
|
transform: none;
|
|||
|
|
transition: box-shadow 0.2s, opacity 0.2s;
|
|||
|
|
}
|
|||
|
|
.ai-assistant-fab:hover:not(.is-loading):not(.is-hidden):not(.is-dragging) {
|
|||
|
|
transform: scale(1.05);
|
|||
|
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.3);
|
|||
|
|
}
|
|||
|
|
/* 抽屉已打开时隐藏悬浮按钮,避免与侧栏内容重叠、重复入口 */
|
|||
|
|
.ai-assistant-fab.is-hidden {
|
|||
|
|
opacity: 0;
|
|||
|
|
pointer-events: none;
|
|||
|
|
visibility: hidden;
|
|||
|
|
}
|
|||
|
|
.ai-assistant-fab.is-loading {
|
|||
|
|
cursor: not-allowed;
|
|||
|
|
pointer-events: auto;
|
|||
|
|
}
|
|||
|
|
.fab-icon-wrap {
|
|||
|
|
width: 32px;
|
|||
|
|
height: 32px;
|
|||
|
|
background: #2563eb;
|
|||
|
|
border-radius: 50%;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
color: #fff;
|
|||
|
|
box-shadow: 0 4px 14px rgba(37, 99, 235, 0.3);
|
|||
|
|
}
|
|||
|
|
.fab-icon {
|
|||
|
|
font-size: 16px;
|
|||
|
|
}
|
|||
|
|
.fab-loading {
|
|||
|
|
animation: fab-spin 0.8s linear infinite;
|
|||
|
|
}
|
|||
|
|
@keyframes fab-spin {
|
|||
|
|
from { transform: rotate(0deg); }
|
|||
|
|
to { transform: rotate(360deg); }
|
|||
|
|
}
|
|||
|
|
.fab-text {
|
|||
|
|
font-size: 14px;
|
|||
|
|
font-weight: 700;
|
|||
|
|
color: #2563eb;
|
|||
|
|
letter-spacing: 0.025em;
|
|||
|
|
}
|
|||
|
|
</style>
|