Files
yudao-ui-admin-vue3/src/components/AiAssistant/AiAssistantProvider.vue
2026-04-17 09:55:14 +08:00

655 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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 单边约 16384pxscale 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>