This commit is contained in:
2026-04-17 09:55:14 +08:00
parent 5210a140ba
commit 2103173b9d
21 changed files with 11430 additions and 0 deletions

View File

@@ -0,0 +1,654 @@
<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>