Commit 51518ea7 by pangchong

feat(aiAssistant): 添加全局 AI 智能客服小助手组件

- 在主布局中集成 AiAssistant 组件入口
- 实现悬浮客服入口按钮,可拖动并支持磁力吸边功能
- 开发可拖动的客服对话窗口,支持最小化与新对话功能
- 设计多分类问题导航及上下文相关专属建议栏
- 支持用户输入联想提示与实时消息流式渲染
- 实现消息复制、满意度反馈和快捷操作按钮功能
- 添加机器人思考中的动画提示及输入区快捷指令
- 编写符合主题颜色的样式,支持多种交互动效
- 构建常见问题及回答数据结构,支持关键词匹配与相关问题推荐
parent 4ba6d93e
...@@ -11,11 +11,15 @@ ...@@ -11,11 +11,15 @@
<CommonImportModal ref="globalImportModalRef" /> <CommonImportModal ref="globalImportModalRef" />
<CommonUploadModal ref="globalUploadModalRef" /> <CommonUploadModal ref="globalUploadModalRef" />
<CommonDownloadModal ref="globalDownloadModalRef" /> <CommonDownloadModal ref="globalDownloadModalRef" />
<!-- 全局 AI 智能客服小助手 -->
<AiAssistant />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useAppStore } from '@/store/app/index' import { useAppStore } from '@/store/app/index'
import AiAssistant from './components/AiAssistant/index.vue'
const themeVars = useThemeVars() const themeVars = useThemeVars()
const appStore = useAppStore() const appStore = useAppStore()
......
export type MessageSender = 'user' | 'bot' | 'system'
export type FaqCategory =
| 'all'
| 'insert' // 元素插入与排版
| 'file' // 文件与导出
| 'translate' // 翻译与对比
| 'tree' // 节点树与结构
| 'settings' // 快捷键与设置
export interface FaqAction {
label: string
type: 'event' | 'link' | 'copy'
payload: string
icon?: string
}
export interface FaqItem {
id: string
category: FaqCategory
categoryName: string
title: string
keywords: string[] // 匹配关键词(含拼音缩写)
synonyms?: string[] // 别名或相似句式
answer: string // 回答富文本/步骤详情
steps?: string[] // 步骤式说明
highlights?: string[] // 关键要点或注意事项
actions?: FaqAction[] // 关联快捷操作按钮
relatedQuestions?: string[] // 推荐的相关问题ID
isHot?: boolean // 是否为猜你想问/热点问题
contextTags?: string[] // 关联的 XML 标签名(当用户选中该标签时优先推荐)
}
export interface ChatMessage {
id: string
sender: MessageSender
timestamp: string | number
content: string
faq?: FaqItem
matchedFaqs?: FaqItem[] // 当命中多个候选问题时
feedback?: 'helpful' | 'unhelpful' | null
isTyping?: boolean
streamContent?: string // 用于打字机流式输出展示
}
export interface CategoryInfo {
key: FaqCategory
label: string
icon?: string
}
export interface MatchResult {
type: 'exact' | 'candidates' | 'none'
bestMatch: FaqItem | null
candidates: FaqItem[]
related: FaqItem[]
}
export const FAQ_CATEGORIES: CategoryInfo[] = [
{ key: 'all', label: '全部问题' },
{ key: 'insert', label: '元素与排版' },
{ key: 'file', label: '文件与导出' },
{ key: 'translate', label: '翻译与对比' },
{ key: 'tree', label: '节点树结构' },
{ key: 'settings', label: '设置与快捷键' }
]
export const FAQ_DATABASE: FaqItem[] = [
// ══════════════ 1. 元素插入与排版 (insert) ══════════════
{
id: 'faq_insert_mode',
category: 'insert',
categoryName: '元素与排版',
title: '“内部”和“下方”插入模式有什么区别?',
keywords: ['插入模式', '内部', '下方', '模式', '子节点', '同级', '层级', '位置', 'moshi', 'neibu', 'xiafang', 'cr'],
synonyms: ['怎么选择插入位置', '内部插入和下方插入', '怎么插到里面', '怎么插到同级', '同级还是子级'],
answer: '在顶部工具栏最左侧,您可以随时切换当前节点的【插入模式】:',
steps: [
'【内部】:将新元素作为当前选中节点的“子元素”插入(如果选中节点支持包含子级)。',
'【下方】:将新元素作为当前选中节点的“同级下一个元素”插入(并列同层关系)。'
],
highlights: ['若要在已有步骤中补充段落或附图,请选“内部”;若要新建下一步骤或段落,请选“下方”。'],
relatedQuestions: ['faq_insert_graphic', 'faq_insert_table', 'faq_insert_template'],
isHot: true
},
{
id: 'faq_insert_graphic',
category: 'insert',
categoryName: '元素与排版',
title: '如何插入工卡附图(GRAPHIC)?',
keywords: ['插入图片', '附图', 'graphic', '图片', '图纸', '插图', '装配图', '原理图', 'gnbr', 'ft', 'futu', 'tp', 'tupian', 'tuzhi'],
synonyms: ['怎么加图片', '怎么插入附图', '添加工卡图纸', '图片怎么上传', '图纸怎么插入'],
answer: '工卡支持插入航空器部件装配原理图、图纸附图等图形节点(<GRAPHIC>)。',
steps: [
'在左侧节点树或中间编辑区,选中需要插入图片的目标节点。',
'在顶部工具栏点击【附图】按钮。',
'在弹出的“插入附图”窗口中,输入或选择图号标识(如 GNBR/Graphic Name)、标题及排版尺寸。',
'点击【确定插入】,系统将自动生成对应的 GRAPHIC 节点并渲染大图预览。'
],
highlights: ['支持在富文本中直接悬浮预览大图与图纸编号。'],
actions: [{ label: '插入附图', type: 'event', payload: 'trigger_insert_graphic' }],
relatedQuestions: ['faq_insert_mode', 'faq_insert_table', 'faq_insert_inline_ref'],
contextTags: ['GRAPHIC', 'GRPHCREF'],
isHot: true
},
{
id: 'faq_insert_table',
category: 'insert',
categoryName: '元素与排版',
title: '如何插入和编辑表格(TABLE)?',
keywords: ['表格', 'table', '插入表格', '行', '列', '单元格', '合并单元格', '表头', 'cals', 'bg', 'biaoge', 'tgroup', 'row', 'entry'],
synonyms: ['怎么建表格', '如何画表格', '添加表格', '修改表格', '加行加列'],
answer: '系统内置完整的专业航空表格生成器,支持自定义行列数与对齐方式。',
steps: [
'选中目标位置,点击顶部工具栏的【表格】按钮。',
'设置表格的行数、列数、表头样式(如 REFERENCE / QTY / DESIGNATION 等)。',
'点击确认后即可生成表格。在编辑区中可直接点击单元格录入中英文文字。',
'支持在表格操作区进行增删行、增删列及边框调整。'
],
highlights: ['表格导出时将严格转换为符合 DTD 规范的 CALS Table / OASIS Table 格式。'],
actions: [{ label: '插入表格', type: 'event', payload: 'trigger_insert_table' }],
relatedQuestions: ['faq_insert_mode', 'faq_insert_signoff', 'faq_insert_record_line'],
contextTags: ['TABLE', 'TGROUP', 'THEAD', 'TBODY', 'ROW', 'ENTRY', 'COLSPEC'],
isHot: true
},
{
id: 'faq_insert_template',
category: 'insert',
categoryName: '元素与排版',
title: '如何插入双语复合步骤模板(TEMPLATE)?',
keywords: ['模板', 'template', '步骤模板', '中英文', '双语', '对照', 'step', '复合模板', 'parac', 'para', 'mb', 'moban', 'bz', 'buzhou'],
synonyms: ['怎么插步骤', '双语对照怎么写', '添加步骤模板', '常用模板', '步骤怎么加'],
answer: '复合模板可快速生成标准的 STEP(步骤)、PARAC(中文段落)与 PARA(英文段落)复合结构。',
steps: [
'选中父级节点或目标步骤后,点击顶部工具栏的【模板】按钮。',
'选择预设模板类型(如标准检修步骤、警告提示 WARNING、注意说明 NOTE 等)。',
'点击应用后,系统自动在当前位置插入中英文对照的规范结构。'
],
highlights: ['使用模板可大幅减少手动编写 XML 标签的工作量,且 100% 符合 DTD 结构规范。'],
actions: [{ label: '打开模板库', type: 'event', payload: 'trigger_insert_template' }],
relatedQuestions: ['faq_batch_translate', 'faq_insert_fragment', 'faq_insert_alert'],
contextTags: ['STEP', 'TASK', 'SUBTASK', 'PRETOPIC', 'TOPIC'],
isHot: true
},
{
id: 'faq_insert_signoff',
category: 'insert',
categoryName: '元素与排版',
title: '如何插入质量签字栏(SIGNOFF)?',
keywords: ['签字', '签字点', 'signoff', '签章', '工作者', '检查者', 'per.by', 'insp.by', '签署', '质检', 'qz', 'qianzi'],
synonyms: ['怎么加签字栏', '工作者签字', '检查者签字', '添加签署框', '签字表格'],
answer: '质量签字栏用于在工卡关键步骤后附加工作者(Per.By)、检查者(Insp.By)或质量员签章框。',
steps: [
'在需要签字确认的步骤节点处,点击顶部工具栏的【签字点】按钮。',
'选择签字类型(单人签署、双人互检、带工卡编号签章栏等)。',
'确认后自动生成标准工卡签字表格,打印和预览时均可完整呈现。'
],
highlights: ['导出的签字栏在纸质打印与电子签署场景下均采用符合规范的物理黑实线边框保障。'],
actions: [{ label: '插入签字点', type: 'event', payload: 'trigger_insert_signoff' }],
relatedQuestions: ['faq_insert_selection', 'faq_insert_record_line', 'faq_preview_card'],
contextTags: ['SIGNOFF']
},
{
id: 'faq_insert_selection',
category: 'insert',
categoryName: '元素与排版',
title: '如何插入单选 / 多选项组(SELECTION)?',
keywords: [
'单选',
'多选',
'选项组',
'selection',
'checkbox',
'radio',
'选择题',
'勾选项',
'符合性',
'xx',
'xuanxiang',
'dx',
'danxuan',
'duoxuan'
],
synonyms: ['怎么加勾选框', '添加单选框', '插入选项', '添加符合性检查', '多选框'],
answer: '选项组用于在工卡执行中提供符合性判断(如“合格/不合格”、“换件/原装”)。',
steps: [
'定位到需要添加选择分支的节点,点击工具栏【选项组】。',
'设置选项模式(单选 Radio / 多选 Checkbox),并添加各个选项的中英文描述。',
'点击插入后,工卡中将展示规范的勾选卡片。'
],
actions: [{ label: '插入选项组', type: 'event', payload: 'trigger_insert_selection' }],
relatedQuestions: ['faq_insert_record_line', 'faq_insert_signoff'],
contextTags: ['SELECTION', 'RECORD-ITEM']
},
{
id: 'faq_insert_record_line',
category: 'insert',
categoryName: '元素与排版',
title: '如何插入数据记录项(RECORD-LINE)?',
keywords: ['数据记录', '填报', 'record-line', '下划线', '测量值', '填空', '单位', '实测', '电阻', '扭矩', 'jl', 'jilu', 'shuju'],
synonyms: ['怎么加填空下划线', '记录实测数据', '添加测量值', '下划线输入', '实测值'],
answer: '数据记录项用于生成带有下划线填报区域和工程单位(如 mm、Ω、V、psi)的数据输入行。',
steps: [
'点击工具栏的【数据记录项】按钮。',
'输入测试项目名称(如“电阻测量值”)、预设默认值与单位(如“Ω”)。',
'生成后将在工卡中展示规范的下划线填空区域。'
],
relatedQuestions: ['faq_insert_signoff', 'faq_insert_selection'],
contextTags: ['RECORD-LINE', 'RECORD', 'RECORD-ITEMS']
},
{
id: 'faq_insert_pagebreak',
category: 'insert',
categoryName: '元素与排版',
title: '如何插入强制分页符(PAGEBREAK)?',
keywords: ['分页', '分页符', 'pagebreak', '换页', '打印换页', '断页', '切页', 'fyf', 'fenyefu', 'fy'],
synonyms: ['怎么强制换页', '打印时怎么下一页', '分页怎么加', '打印断页'],
answer: '强制分页符用于在特定节点下方切断页面,确保在打印或导出 PDF/HTML 时从新的一页开始排版。',
steps: [
'在需要换页的节点位置,点击顶部工具栏的【分页符】按钮。',
'编辑区会出现虚线标识【分页符 [Page Break]】。',
'在点击【预览工卡】或【下载html】打印时,该位置将自动触发 CSS page-break 换页。'
],
relatedQuestions: ['faq_preview_card', 'faq_download_html'],
contextTags: ['PAGEBREAK']
},
{
id: 'faq_insert_fragment',
category: 'insert',
categoryName: '元素与排版',
title: '如何自定义插入 XML 代码片段?',
keywords: ['xml片段', '代码片段', 'fragment', '源码插入', 'warning', 'caution', 'note', '手写xml', 'pd', 'pianduan', 'daima'],
synonyms: ['怎么直接写xml', '插入自定义代码', '粘贴xml标签', '手写标签'],
answer: '如果您有准备好的 XML 片段(例如整段 WARNING 或特殊标签),可以使用 XML 片段工具快速注入。',
steps: [
'在工具栏点击【XML片段】按钮。',
'在弹出编辑器中粘贴或编写 XML 字符串(支持代码高亮与自动缩进)。',
'系统会自动校验 XML 语法合法性并转换为节点树,插入到指定位置。'
],
actions: [{ label: '插入XML片段', type: 'event', payload: 'trigger_insert_fragment' }],
relatedQuestions: ['faq_insert_template', 'faq_dtd_validate', 'faq_insert_alert']
},
{
id: 'faq_insert_alert',
category: 'insert',
categoryName: '元素与排版',
title: '如何插入 WARNING、CAUTION 与 NOTE 警示块?',
keywords: ['warning', 'caution', 'note', '警告', '警戒', '注意', '提示块', '警示', 'jg', 'jinggao', 'jj', 'jingjie', 'zy', 'zhuyi'],
synonyms: ['怎么加警告', '添加注意说明', '警戒怎么写', '安全警示'],
answer: '系统严格支持航空标准的三级警示块,并自动根据手册规范进行物理着色呈现:',
steps: [
'【WARNING(警告)】:代表危及人身安全的严重风险,呈现为红色物理着色加粗。',
'【CAUTION(警戒)】:代表可能损坏设备或部件的风险,呈现为橙色物理着色。',
'【NOTE(注意)】:代表操作要点与补充说明,呈现为蓝色物理着色。',
'可通过顶部工具栏【模板】选择或使用【XML片段】直接注入。'
],
highlights: ['此类具有强制规范色彩的节点在暗黑模式及 PDF 导出时均保持标准物理颜色呈现。'],
actions: [{ label: '打开模板库', type: 'event', payload: 'trigger_insert_template' }],
relatedQuestions: ['faq_insert_template', 'faq_insert_fragment'],
contextTags: ['WARNING', 'CAUTION', 'NOTE']
},
{
id: 'faq_insert_inline_ref',
category: 'insert',
categoryName: '元素与排版',
title: '如何在文本中插入交叉引用(REFBLOCK / REFINT)与功能号(EIN)?',
keywords: [
'引用',
'refblock',
'refint',
'refext',
'ein',
'功能号',
'grphcref',
'交叉引用',
'附图引用',
'yy',
'yinyong',
'gnh',
'gongnenghao'
],
synonyms: ['怎么加引用', '怎么关联其他章节', '插入功能号', '图纸引用'],
answer: '在富文本编辑段落(PARAC/PARA)中,支持插入符合航空标准的一系列行内引用与标识:',
steps: [
'【REFBLOCK / REFINT / REFEXT】:内部与外部交叉引用,以蓝色文本呈现。',
'【EIN / EINMFR】:电气与功能识别号,以蓝色带下划线格式呈现。',
'【GRPHCREF】:工卡附图引用,点击可联动弹出对应附图大图。',
'【EFFECT / CONEFFECT】:适用性说明,以红色斜体格式规范呈现。'
],
highlights: ['所有行内元素均可在文本块编辑器中就地编辑与删除。'],
relatedQuestions: ['faq_insert_graphic', 'faq_node_locate'],
contextTags: ['REFBLOCK', 'REFINT', 'REFEXT', 'EIN', 'GRPHCREF', 'EFFECT']
},
{
id: 'faq_insert_list',
category: 'insert',
categoryName: '元素与排版',
title: '如何插入和管理多级有序/无序列表(LIST1-LIST7)?',
keywords: ['列表', 'list', 'list1', 'l1item', '有序列表', '无序列表', '层级缩进', '多级列表', 'lb', 'liebiao'],
synonyms: ['怎么加列表', '多级序号', '缩进列表', '列表条目'],
answer: '系统全面支持 DTD 标准的 1~7 级有序列表(LIST1 ~ LIST7)与无序列表(UNLITEM):',
steps: [
'在节点树右键选择【插入子节点】 $\\rightarrow$ 【LIST1】。',
'在列表容器下插入列表条目(如 L1ITEM、L2ITEM)。',
'系统在右侧编辑区自动计算层级缩进并渲染多层中英文列表结构。'
],
relatedQuestions: ['faq_node_tree_ops', 'faq_insert_template'],
contextTags: ['LIST1', 'LIST2', 'LIST3', 'LIST4', 'LIST5', 'LIST6', 'LIST7', 'L1ITEM', 'UNLITEM']
},
// ══════════════ 2. 文件管理与导出 (file) ══════════════
{
id: 'faq_import_xml',
category: 'file',
categoryName: '文件与导出',
title: '如何导入外部 XML 文件?',
keywords: ['导入', '上传', '打开', 'import', 'xml导入', '加载工卡', '本地文件', 'dr', 'daoru', 'sc', 'shangchuan'],
synonyms: ['怎么上传xml', '导入工卡文件', '怎么打开本地xml', '打开已有工卡'],
answer: '系统支持一键导入符合规范的标准工卡 XML 文件,并自动解析为可视化节点树。',
steps: [
'点击工具栏的【导入 XML】按钮。',
'在弹出的文件选择器中选中您的 `.xml` 文件。',
'系统解析成功后,左侧节点树和右侧可视化编辑区将立即同步更新内容。'
],
highlights: ['导入前建议使用【本地暂存】保存当前未完成的编辑内容,以防被覆盖。'],
actions: [{ label: '导入 XML 文件', type: 'event', payload: 'trigger_import_xml' }],
relatedQuestions: ['faq_export_xml', 'faq_stash_manager'],
isHot: true
},
{
id: 'faq_export_xml',
category: 'file',
categoryName: '文件与导出',
title: '如何导出编辑好的工卡 XML 文件?',
keywords: ['导出', '下载', 'export', '保存为xml', '导出xml', '生成xml', '保存工卡', 'dc', 'daochu', 'xz', 'xiazai'],
synonyms: ['怎么下载xml', '工卡怎么导出', '保存到本地电脑', '下载xml文件'],
answer: '系统会将当前可视化的所有节点与属性实时序列化为符合航空规范的完整 XML 文件。',
steps: [
'在工具栏点击【导出 XML】按钮。',
'弹出下载确认窗口,可自定义导出文件名(默认带有时间戳,如 `WORK_CARD_YYYYMMDDHHmmss.xml`)。',
'点击下载即可保存到本地磁盘。'
],
highlights: ['导出的 XML 严格包含 XML 声明及 DTD 格式标准。'],
actions: [{ label: '立即导出 XML', type: 'event', payload: 'trigger_export_xml' }],
relatedQuestions: ['faq_import_xml', 'faq_download_html', 'faq_preview_card'],
isHot: true
},
{
id: 'faq_preview_card',
category: 'file',
categoryName: '文件与导出',
title: '如何预览工卡的实际排版和打印效果?',
keywords: ['预览', 'preview', '预览工卡', '查看效果', '打印预览', '查看排版', 'a4预览', 'yl', 'yulan', 'dy', 'dayin'],
synonyms: ['怎么看打印效果', '预览工卡长什么样', '全屏预览', '打印效果'],
answer: '点击工具栏显著的蓝色【预览工卡】按钮,即可进入全屏工卡排版预览模式。',
steps: [
'点击工具栏右上方的【预览工卡】。',
'系统将在全屏弹窗中以真实 A4/打印样式渲染整份工卡(包含表格、附图、双语对照、签字栏及分页符)。',
'在预览窗口中可直接调用系统打印(Ctrl+P)或缩放查看。'
],
actions: [{ label: '立即预览工卡', type: 'event', payload: 'trigger_preview' }],
relatedQuestions: ['faq_download_html', 'faq_export_xml'],
isHot: true
},
{
id: 'faq_download_html',
category: 'file',
categoryName: '文件与导出',
title: '如何下载离线 HTML 格式工卡?',
keywords: ['下载html', 'html', '离线查看', '网页版', '下载网页', '静态工卡', '单文件', 'lx', 'lixian', 'wye'],
synonyms: ['怎么导出html', '下载离线网页', '保存为html', '导出网页'],
answer: '【下载html】功能会将整份工卡及其所有排版样式打包为一个独立的单文件 HTML。',
steps: [
'点击工具栏的【下载html】按钮。',
'浏览器将下载包含完整样式表、中英文排版、附图的 `.html` 文件。',
'双击该 HTML 文件即可在任何无网电脑、手机或平板浏览器中直接查阅与打印。'
],
actions: [{ label: '下载 HTML', type: 'event', payload: 'trigger_download_html' }],
relatedQuestions: ['faq_preview_card', 'faq_export_xml']
},
{
id: 'faq_stash_manager',
category: 'file',
categoryName: '文件与导出',
title: '什么是“本地暂存(Stash)”?如何防丢失?',
keywords: ['暂存', '本地暂存', 'stash', '草稿', '恢复', '防丢失', '版本', '历史暂存', '备份', 'zc', 'zancun', 'cg', 'caogao'],
synonyms: ['怎么存草稿', '电脑关了数据还在吗', '怎么备份当前工卡', '恢复暂存', '暂存版本'],
answer: '本地暂存功能类似于 Git Stash,能够将您当前编辑的工卡快照保存在本地浏览器中,防止意外刷新或关闭导致数据丢失。',
steps: [
'在工具栏点击【本地暂存】按钮。',
'点击“新建暂存”,输入备注名称(如“发动机大修第一版”),系统立即保存当前完整状态。',
'随时可在暂存列表中查看历史快照、对比变动或一键【恢复】到对应版本。'
],
highlights: ['暂存数据存储在本地持久化存储中,即使关闭浏览器或重启电脑也能随时找回。'],
actions: [{ label: '打开本地暂存', type: 'event', payload: 'trigger_stash' }],
relatedQuestions: ['faq_export_xml', 'faq_compare_card'],
isHot: true
},
{
id: 'faq_xml_size',
category: 'file',
categoryName: '文件与导出',
title: '如何查看当前工卡的文档体积与字数统计?',
keywords: ['大小', '体积', '文档统计', 'xml大小', '字数', '节点数', '性能', 'dx', 'daxiao', 'tj', 'tiji'],
synonyms: ['怎么看工卡有多大', '文件大小', '查看字数'],
answer: '在顶部工具栏右侧偏好设置旁,实时显示当前文档的序列化大小(如 `18.5 KB`)。',
steps: [
'系统在每次编辑、新增节点或修改文本后,后台自动计算当前 XML 树的完整体积。',
'如果工卡体积过大,可通过折叠非当前编辑节点或拆分子工卡提升编辑流畅度。'
],
relatedQuestions: ['faq_export_xml', 'faq_shortcuts']
},
// ══════════════ 3. 翻译与对比 (translate) ══════════════
{
id: 'faq_batch_translate',
category: 'translate',
categoryName: '翻译与对比',
title: '如何进行中英文“批量翻译”?',
keywords: ['翻译', '批量翻译', 'translate', '中英文', '英译中', '中译英', '自动翻译', '语言', 'para', 'parac', 'fy', 'fanyi', 'plfy'],
synonyms: ['怎么一键翻译', '自动把英文转中文', '中英文对照生成', '批量翻译工卡'],
answer: '系统提供智能航空术语批量翻译引擎,能够一键扫描工卡中所有待翻译节点并自动填入对应译文。',
steps: [
'在顶部工具栏点击【批量翻译】按钮。',
'系统弹窗展示待翻译词条统计(英文段落 PARA 与中文段落 PARAC)。',
'点击开始翻译,系统后台自动调用航空专业翻译服务,并高亮展示翻译进度。',
'翻译完成后可一键应用回工卡树中。'
],
highlights: ['翻译结果优先匹配专业航空机务词典,确保术语标准规范。'],
actions: [{ label: '批量翻译', type: 'event', payload: 'trigger_batch_translate' }],
relatedQuestions: ['faq_extract_translate', 'faq_search_translate'],
isHot: true
},
{
id: 'faq_extract_translate',
category: 'translate',
categoryName: '翻译与对比',
title: '如何“提取翻译”对照条目?',
keywords: ['提取翻译', '对照表', 'extract', '词条提取', '双语对照', '导出词条', '语料', 'tqfy', 'tiqu'],
synonyms: ['怎么把翻译单独导出来', '提取所有中英文', '导出双语表'],
answer: '提取翻译功能可将整份工卡中的全部中英文对照对(PARA / PARAC)汇总抽取为结构化清单。',
steps: [
'点击工具栏的【提取翻译】按钮。',
'在弹出面板中浏览工卡内所有的双语对照条目。',
'支持按关键词检索、筛选未翻译条目,或复制/导出对照清单。'
],
actions: [{ label: '提取翻译对照', type: 'event', payload: 'trigger_extract_translate' }],
relatedQuestions: ['faq_batch_translate', 'faq_search_translate']
},
{
id: 'faq_search_translate',
category: 'translate',
categoryName: '翻译与对比',
title: '如何“搜索翻译”数据库与专业词典?',
keywords: ['搜索翻译', '查词典', '术语库', '词库', 'search translate', '航空术语', '词汇查询', 'ssfy', 'sousuo', 'cidian'],
synonyms: ['怎么查专业单词', '机务词汇查询', '搜索翻译库', '专业英语查词'],
answer: '内置航空机务专业翻译语料库,可随时查询专业术语的标准中文/英文翻译。',
steps: [
'点击工具栏的【搜索翻译】按钮。',
'输入中文或英文关键词(例如“Torque wrench”、“Safety wire”、“液压管路”)。',
'即时查看权威匹配释义与上下文例句,并支持一键复制到剪贴板。'
],
actions: [{ label: '搜索翻译词典', type: 'event', payload: 'trigger_search_translate' }],
relatedQuestions: ['faq_batch_translate', 'faq_extract_translate']
},
{
id: 'faq_compare_card',
category: 'translate',
categoryName: '翻译与对比',
title: '如何使用“对比工卡”查看两份工卡的差异?',
keywords: ['对比', '对比工卡', 'compare', 'diff', '版本对比', '改动', '差异', '修改记录', '比对', 'db', 'duibi', 'chayi'],
synonyms: ['怎么看修改了哪里', '两份工卡怎么对比', '比对不同版本', '差异对比'],
answer: '对比工卡功能基于强大的 Diff 算法,直观高亮展示两份工卡在节点结构、文本内容、表格及属性上的增删改变动。',
steps: [
'在工具栏点击【对比工卡】按钮。',
'选择或上传待比对的目标工卡文件(也可以选择与本地暂存版本比对)。',
'系统以双栏分屏或统一 Diff 视图展示:绿色表示新增、红色表示删除、黄色表示修改。'
],
actions: [{ label: '打开对比工卡', type: 'event', payload: 'trigger_compare' }],
relatedQuestions: ['faq_stash_manager', 'faq_export_xml'],
isHot: true
},
// ══════════════ 4. 节点树与结构 (tree) ══════════════
{
id: 'faq_node_tree_ops',
category: 'tree',
categoryName: '节点树结构',
title: '左侧节点树有哪些操作?右键菜单怎么用?',
keywords: ['节点树', '右键菜单', '删除节点', '重命名', '复制', '剪切', '粘贴', '新增节点', '结构树', 'jds', 'jiedian', 'yj', 'youjian'],
synonyms: ['怎么删节点', '右键有哪些功能', '怎么复制节点', '修改节点名称', '树菜单'],
answer: '左侧节点树展示工卡的层级骨架(DOM 树结构),支持丰富的右键上下文菜单操作:',
steps: [
'【右键菜单】:在任意节点上右键,可选择:',
' · 向上插入同级节点 / 向下插入同级节点',
' · 插入子节点(如在 TASK 下插入 STEP)',
' · 复制 (Copy) / 剪切 (Cut) / 粘贴 (Paste)',
' · 重命名节点标签名 / 删除节点 (Delete)',
' · 快速查看并编辑该节点的 XML 属性'
],
highlights: ['在节点树中点击任意节点,右侧编辑区会自动平滑滚动并聚焦到该节点。'],
relatedQuestions: ['faq_node_drag', 'faq_dtd_validate', 'faq_node_locate']
},
{
id: 'faq_node_drag',
category: 'tree',
categoryName: '节点树结构',
title: '如何通过拖拽调整节点的先后顺序或层级?',
keywords: ['拖拽', '拖动', '移动节点', '排序', '调整顺序', 'drag', '改变层级', '重排', 'tz', 'tuozhuai', 'paixu'],
synonyms: ['怎么把步骤往前移', '节点怎么上下拖动', '调整步骤顺序', '拖动节点'],
answer: '节点树支持自由拖拽排序:',
steps: [
'按住左侧节点树中想要移动的节点。',
'拖拽至目标位置(上方、下方或某个父节点内部)。',
'松开鼠标即可完成重排,右侧文档内容将实时自动更新排版。'
],
highlights: ['操作失误无需担心,可随时按快捷键 Ctrl+Z 撤销回拖拽前的状态。'],
relatedQuestions: ['faq_node_tree_ops', 'faq_shortcuts']
},
{
id: 'faq_node_locate',
category: 'tree',
categoryName: '节点树结构',
title: '节点树点击后编辑区是如何精确定位与高亮的?',
keywords: ['定位', '联动', '滚动居中', '高亮', '最近块', '祖先链', 'focus', '选中', 'dw', 'dingwei', 'gl', 'gaoliang'],
synonyms: ['怎么跳到选中位置', '为什么点击没反应', '编辑区怎么联动', '滚动定位'],
answer: '系统内置智能祖先链遍历算法与最近渲染块查找机制(findNearestBlockIdx):',
steps: [
'点击左侧树节点时,系统自底向上查找最近的已挂载渲染容器(如 SUBTASK、PRETOPIC 等)。',
'精确定位到编辑区对应卡片并平滑滚动居中。',
'对于复合行内节点(如 CONNBR、TOOLNBR),自动同步高亮其父容器行,保持焦点一致。'
],
relatedQuestions: ['faq_node_tree_ops', 'faq_composite_containers']
},
{
id: 'faq_dtd_validate',
category: 'tree',
categoryName: '节点树结构',
title: '系统如何进行 DTD 规范语法校验与防错?',
keywords: ['dtd', '校验', '合法性', '规范', '报错', 'validate', '格式检查', '语法', '防错', 'jy', 'jiaoyan', 'gf', 'guifan'],
synonyms: ['工卡格式对不对', '怎么检查错误', '标签不合规怎么办', '语法检查'],
answer: '系统内置完整的航空标准 DTD 校验规则引擎。',
steps: [
'在新增或修改节点时,系统会自动依据 DTD 规则判断该节点允许包含的子标签与必须属性。',
'若存在不符合规范的嵌套关系或遗漏必填属性,系统会在属性面板及节点树上给出醒目的黄色/红色预警提示。',
'点击工具栏上的校验功能即可查看全篇工卡的合规诊断报告。'
],
relatedQuestions: ['faq_insert_fragment', 'faq_node_tree_ops', 'faq_attribute_editor']
},
{
id: 'faq_attribute_editor',
category: 'tree',
categoryName: '节点树结构',
title: '如何查看和修改 XML 节点的属性(Attribute)?',
keywords: ['属性', 'attribute', 'applic', 'id', 'revision', '修改属性', '节点属性', 'sx', 'shuxing'],
synonyms: ['怎么改属性', '修改节点id', '添加适用性属性', '属性面板在哪里'],
answer: '在左侧节点树或右侧编辑区选中节点后:',
steps: [
'右键点击节点选择【编辑属性】,或在右侧属性抽屉中直接查看。',
'面板将列出该 DTD 节点支持的所有标准属性(如 APPLIC、KEY、NBR、REV 等)。',
'输入或修改属性值后,系统将实时同步更新至 XML 数据树中。'
],
relatedQuestions: ['faq_node_tree_ops', 'faq_dtd_validate']
},
{
id: 'faq_composite_containers',
category: 'tree',
categoryName: '节点树结构',
title: '什么是复合结构特殊容器(CBDATA / TED / CON 等)?',
keywords: ['复合容器', 'cbdata', 'ted', 'con', 'graphic', 'eindata', 'expd', '断路器', '工具', '消耗品', 'fh', 'fuhe', 'dlq', 'duanluqi'],
synonyms: ['怎么加断路器', '工具清单怎么做', '消耗品表格', '复合结构说明'],
answer: '针对航空标准中具有固定多列结构或成对渲染的特殊业务实体,系统采用复合结构容器:',
steps: [
'【CBDATA】:电路断路器数据行(包含 PAN、CBNAME、CB、CBLOC 四列)。',
'【TED】:专用工具行(包含 TOOLNAME 工具名与 TOOLNBR 件号)。',
'【CON】:消耗品材料行(包含 CONNAME 消耗品名与 CONNBR 件号)。',
'【EINDATA】:功能号适用性行(包含 EIN 功能号与相关属性)。'
],
highlights: ['复合容器下的各个子元素均支持就地独立编辑、独立高亮与定位。'],
relatedQuestions: ['faq_node_locate', 'faq_insert_table'],
contextTags: ['CBDATA', 'TED', 'CON', 'EINDATA', 'EXPD']
},
// ══════════════ 5. 设置与快捷键 (settings) ══════════════
{
id: 'faq_shortcuts',
category: 'settings',
categoryName: '设置与快捷键',
title: '工卡 XML 编辑器有哪些常用快捷键?',
keywords: [
'快捷键',
'键盘快捷键',
'ctrl+z',
'ctrl+y',
'ctrl+shift+d',
'ctrl+f',
'撤销',
'重做',
'主题切换',
'shortcuts',
'kjj',
'kuaijiejian',
'cx',
'chexiao'
],
synonyms: ['有哪些按键操作', '撤销重做快捷键', '快捷键列表', '搜索快捷键', '深色模式快捷键'],
answer: '熟练使用快捷键可大幅提升工卡编写效率:',
steps: [
'【Ctrl + Z】:撤销上一步操作(Undo)',
'【Ctrl + Y】 或 【Ctrl + Shift + Z】:重做恢复(Redo)',
'【Ctrl + Shift + D】:快速切换深色 / 浅色主题模式',
'【Ctrl + F】:全局搜索与定位文本内容',
'【Esc】:关闭当前打开的弹窗或退出搜索'
],
highlights: ['工具栏顶部同样提供了显式的【撤销】、【重做】与【亮暗色切换】图标按钮。'],
relatedQuestions: ['faq_theme_settings', 'faq_stash_manager'],
isHot: true
},
{
id: 'faq_theme_settings',
category: 'settings',
categoryName: '设置与快捷键',
title: '如何切换暗黑模式、主题色或调整字号?',
keywords: [
'暗黑模式',
'黑夜模式',
'明亮模式',
'主题色',
'换皮肤',
'字体大小',
'字号',
'偏好设置',
'皮肤',
'ah',
'anhei',
'zhuti',
'zt',
'zh'
],
synonyms: ['怎么开夜间模式', '怎么改颜色', '字体太小怎么变大', '偏好设置在哪里', '改字体'],
answer: '系统支持极高自由度的界面个性化定制:',
steps: [
'【明暗切换】:点击顶部工具栏右上角的【太阳/月亮】图标,即可瞬间在亮色与暗色模式间切换。',
'【偏好设置】:点击右上角【设置齿轮】图标打开抽屉:',
' · 主题色板:可自由挑选数十种品牌色(科技蓝、极光绿、活力橙等)或自定义 HEX 颜色。',
' · 字体大小:滑动滑块实时调整全局界面字号(12px ~ 20px)。',
' · 辅助模式:支持一键开启【色弱模式】或【灰色模式】。',
' · 界面布局:自定义左侧节点树的默认展开宽度与自动折叠阈值。'
],
actions: [{ label: '打开偏好设置', type: 'event', payload: 'trigger_open_settings' }],
relatedQuestions: ['faq_shortcuts', 'faq_accessibility_modes', 'faq_auto_expand_setting'],
isHot: true
},
{
id: 'faq_accessibility_modes',
category: 'settings',
categoryName: '设置与快捷键',
title: '如何开启色弱模式或灰色模式?',
keywords: ['色弱模式', '灰色模式', '辅助功能', '无障碍', '颜色过滤', '高对比度', 'sr', 'seruo', 'huise', 'hs', 'wza'],
synonyms: ['色盲模式怎么开', '黑白模式', '怎么开灰色模式'],
answer: '为了方便各类视觉需求的工作人员,系统内置了无障碍辅助模式:',
steps: [
'点击右上角【偏好设置】齿轮图标打开设置抽屉。',
'在“辅助模式”区域,可勾选开启【色弱模式】(调整红绿对比度)或【灰色模式】(全界面灰度渲染)。',
'设置即时全局生效,并会自动保存在本地配置中。'
],
actions: [{ label: '打开偏好设置', type: 'event', payload: 'trigger_open_settings' }],
relatedQuestions: ['faq_theme_settings']
},
{
id: 'faq_auto_expand_setting',
category: 'settings',
categoryName: '设置与快捷键',
title: '什么是“插入节点自动展开”偏好设置?',
keywords: ['自动展开', 'autoexpand', '偏好设置', '插入节点', '折叠状态', '树展开', 'zdzk', 'zidongzhankai', 'zk'],
synonyms: ['插入节点怎么不自动展开', '怎么保持树折叠', '自动展开在哪里关'],
answer: '该设置用于控制在节点树或表格中新增节点时,树结构是否自动级联展开:',
steps: [
'【开启】:插入新节点时,自动展开新增节点自身及其所有父祖先节点,便于立刻查看子结构。',
'【关闭】:插入新节点时,保持原有树节点的折叠状态,实现静默插入,避免大纲树被频繁打乱。',
'可在右上角【偏好设置】抽屉中自由开启或关闭。'
],
actions: [{ label: '打开偏好设置', type: 'event', payload: 'trigger_open_settings' }],
relatedQuestions: ['faq_node_tree_ops', 'faq_theme_settings']
}
]
export const DEFAULT_WELCOME_MESSAGE = `您好!我是工卡 XML 编辑器的 **AI 智能客服小助手** 🤖
很高兴为您服务!我已经全面掌握了本系统的所有功能,无论您遇到任何排版、编辑、翻译、对比或导出问题,都可以直接向我提问。
💡 **猜你想问(点击即可快速解答):**`
export const HOT_QUESTIONS = FAQ_DATABASE.filter((item) => item.isHot)
import type { FaqItem, FaqCategory, ChatMessage, FaqAction, MatchResult } from '../constants'
import { FAQ_CATEGORIES, FAQ_DATABASE, HOT_QUESTIONS, DEFAULT_WELCOME_MESSAGE } from '../constants'
import { useEditorStore } from '@/store/editor'
import { useAiAssistantStore } from '@/store/aiAssistant'
import { storeToRefs } from 'pinia'
/**
* 文本清洗规范化
*/
export const cleanText = (text: string): string => {
return text
.toLowerCase()
.replace(/[\s\r\n\t]+/g, '')
.replace(/[??!!,,。、.~`@#$%^&*()_+\-=[\]{};':"\\|<>/]/g, '')
.replace(/(请问|帮我|如何|怎么|怎样|我想|想要|需要|能否|可以|一下|在哪|在什么地方|怎么弄|怎么搞|怎么做|的|呢|吧|啊|吗)/g, '')
}
/**
* 计算两个文本的相似度与包含得分(支持关键词、拼音简拼及同义词)
*/
export const calculateScore = (query: string, rawQuery: string, item: FaqItem): number => {
let score = 0
const rawLower = rawQuery.toLowerCase()
const itemTitleClean = cleanText(item.title)
// 1. 标题完全或高相似匹配
if (rawLower.includes(item.title.toLowerCase()) || item.title.toLowerCase().includes(rawLower)) {
score += 100
} else if (query && itemTitleClean.includes(query)) {
score += 80
}
// 2. 关键词与拼音缩写匹配
for (const kw of item.keywords) {
const kwLower = kw.toLowerCase()
const kwClean = cleanText(kw)
if (rawLower === kwLower) {
score += 70 // 完全匹配关键词/简拼
} else if (rawLower.includes(kwLower)) {
score += 50
} else if (query && kwClean && (query.includes(kwClean) || kwClean.includes(query))) {
score += 35
}
}
// 3. 同义句式匹配
if (item.synonyms) {
for (const syn of item.synonyms) {
const synClean = cleanText(syn)
if (rawLower.includes(syn.toLowerCase()) || (query && synClean && (query.includes(synClean) || synClean.includes(query)))) {
score += 45
}
}
}
// 4. 正文与步骤匹配
const answerClean = cleanText(item.answer + (item.steps ? item.steps.join('') : ''))
if (query && query.length >= 2 && answerClean.includes(query)) {
score += 20
}
// 5. 分类匹配
if (rawLower.includes(item.categoryName.toLowerCase())) {
score += 25
}
return score
}
/**
* 核心智能匹配函数
*/
export const matchFaq = (rawQuery: string): MatchResult => {
const trimmed = rawQuery.trim()
if (!trimmed) {
return {
type: 'none',
bestMatch: null,
candidates: [],
related: HOT_QUESTIONS.slice(0, 4)
}
}
const query = cleanText(trimmed)
const scored = FAQ_DATABASE.map((item) => ({
item,
score: calculateScore(query, trimmed, item)
}))
.filter((entry) => entry.score > 0)
.sort((a, b) => b.score - a.score)
if (scored.length === 0) {
return {
type: 'none',
bestMatch: null,
candidates: [],
related: HOT_QUESTIONS.slice(0, 4)
}
}
const highest = scored[0]
// 高分直接命中(得分 >= 40)
if (highest.score >= 40) {
const related: FaqItem[] = []
if (highest.item.relatedQuestions) {
for (const rId of highest.item.relatedQuestions) {
const found = FAQ_DATABASE.find((i) => i.id === rId)
if (found && found.id !== highest.item.id) {
related.push(found)
}
}
}
// 如果相关推荐不够,用第 2、3 个高分结果补充
for (let i = 1; i < scored.length && related.length < 3; i++) {
if (scored[i].score >= 20 && !related.some((r) => r.id === scored[i].item.id)) {
related.push(scored[i].item)
}
}
return {
type: 'exact',
bestMatch: highest.item,
candidates: [],
related
}
}
// 中等得分(15 ~ 39 分):返回多个候选问题供用户选择
if (highest.score >= 15) {
const candidates = scored.slice(0, 4).map((s) => s.item)
return {
type: 'candidates',
bestMatch: null,
candidates,
related: []
}
}
// 未能有效命中
return {
type: 'none',
bestMatch: null,
candidates: [],
related: HOT_QUESTIONS.slice(0, 4)
}
}
/**
* 根据 ID 获取指定问题
*/
export const getFaqById = (id: string): FaqItem | undefined => {
return FAQ_DATABASE.find((item) => item.id === id)
}
/**
* 按分类筛选问题列表
*/
export const getFaqsByCategory = (category: FaqCategory): FaqItem[] => {
if (category === 'all') {
return FAQ_DATABASE
}
return FAQ_DATABASE.filter((item) => item.category === category)
}
/**
* 输入框实时联想建议
*/
export const getSuggestions = (input: string, limit = 5): FaqItem[] => {
const trimmed = input.trim()
if (!trimmed) return []
const query = cleanText(trimmed)
return FAQ_DATABASE.map((item) => ({
item,
score: calculateScore(query, trimmed, item)
}))
.filter((entry) => entry.score >= 20)
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map((entry) => entry.item)
}
/**
* AI 助手核心业务与交互逻辑 Hook(极致丝滑流式动效架构)
*/
export const useAiAssistant = () => {
const themeVars = useThemeVars()
const eventBus = useEventBus()
const editorStore = useEditorStore()
const aiStore = useAiAssistantStore()
// 解构 Pinia 响应式状态
const { isOpen, isMinimized, unreadCount, activeCategory, badgePos, winPos, messageList } = storeToRefs(aiStore)
// 本地 UI 交互临时状态
const inputQuery = ref('')
const isTyping = ref(false)
const isStreaming = ref(false)
const streamingContent = ref('')
const streamingMsg = ref<ChatMessage | null>(null)
const chatScrollRef = ref<HTMLElement | null>(null)
const inputRef = ref<HTMLInputElement | null>(null)
const suggestions = ref<FaqItem[]>([])
const badgeRef = ref<HTMLElement | null>(null)
const windowRef = ref<HTMLElement | null>(null)
const isBadgeSnapping = ref(false)
const isInitialized = ref(false)
// 上下文感知推荐:根据当前选中的节点动态计算关联 FAQ
const contextFaqs = computed<FaqItem[]>(() => {
const selectedTag = editorStore.selectedNode?.tagName
if (!selectedTag) return []
return FAQ_DATABASE.filter((item) => item.contextTags && item.contextTags.includes(selectedTag)).slice(0, 3)
})
// 初始化悬浮球与窗口默认位置
const initPositions = () => {
const winW = window.innerWidth || 1200
const winH = window.innerHeight || 800
if (!badgePos.value.x || !badgePos.value.y) {
aiStore.setBadgePos({
x: Math.max(16, winW - 250),
y: Math.max(16, winH - 140)
})
}
if (!winPos.value.x || !winPos.value.y) {
aiStore.setWinPos({
x: Math.max(16, winW - 414),
y: Math.max(16, winH - 604)
})
}
isInitialized.value = true
}
// 窗口尺寸变化时约束位置在视口内
const handleResize = () => {
const winW = window.innerWidth || 1200
const winH = window.innerHeight || 800
const badgeW = badgeRef.value?.offsetWidth || 240
const badgeH = badgeRef.value?.offsetHeight || 56
const clampedBadgeX = Math.max(10, Math.min(winW - badgeW - 10, badgePos.value.x))
const clampedBadgeY = Math.max(10, Math.min(winH - badgeH - 10, badgePos.value.y))
aiStore.setBadgePos({ x: clampedBadgeX, y: clampedBadgeY })
const dialogW = isMinimized.value ? 320 : 390
const dialogH = isMinimized.value ? 52 : 580
const clampedWinX = Math.max(10, Math.min(winW - dialogW, winPos.value.x))
const clampedWinY = Math.max(10, Math.min(winH - dialogH, winPos.value.y))
aiStore.setWinPos({ x: clampedWinX, y: clampedWinY })
}
// ── 悬浮球拖拽与磁力吸边逻辑 ──────────────────────────────────────────────────
let isDraggingBadge = false
let badgeStartX = 0
let badgeStartY = 0
let pointerStartX = 0
let pointerStartY = 0
let hasMovedBadge = false
const startBadgeDrag = (clientX: number, clientY: number) => {
isDraggingBadge = true
hasMovedBadge = false
isBadgeSnapping.value = false
pointerStartX = clientX
pointerStartY = clientY
badgeStartX = badgePos.value.x
badgeStartY = badgePos.value.y
const onMove = (e: MouseEvent | TouchEvent) => {
if (!isDraggingBadge) return
const curX = 'touches' in e ? e.touches?.[0]?.clientX : e.clientX
const curY = 'touches' in e ? e.touches?.[0]?.clientY : e.clientY
if (curX === undefined || curY === undefined) return
const dx = curX - pointerStartX
const dy = curY - pointerStartY
if (Math.hypot(dx, dy) > 4) {
hasMovedBadge = true
}
const winW = window.innerWidth
const winH = window.innerHeight
const badgeW = badgeRef.value?.offsetWidth || 180
const badgeH = badgeRef.value?.offsetHeight || 56
aiStore.setBadgePos({
x: Math.max(8, Math.min(winW - badgeW - 8, badgeStartX + dx)),
y: Math.max(8, Math.min(winH - badgeH - 8, badgeStartY + dy))
})
}
const onEnd = () => {
isDraggingBadge = false
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onEnd)
window.removeEventListener('touchmove', onMove)
window.removeEventListener('touchend', onEnd)
if (!hasMovedBadge) {
handleToggleOpen()
} else {
const winW = window.innerWidth
const badgeW = badgeRef.value?.offsetWidth || 220
const snapX = badgePos.value.x + badgeW / 2 < winW / 2 ? 16 : winW - badgeW - 16
isBadgeSnapping.value = true
aiStore.setBadgePos({
x: snapX,
y: badgePos.value.y
})
setTimeout(() => {
isBadgeSnapping.value = false
}, 350)
}
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onEnd)
window.addEventListener('touchmove', onMove, { passive: false })
window.addEventListener('touchend', onEnd)
}
const handleBadgeMouseDown = (e: MouseEvent) => {
if (e.button !== 0) return
startBadgeDrag(e.clientX, e.clientY)
}
const handleBadgeTouchStart = (e: TouchEvent) => {
if (e.touches && e.touches.length > 0) {
startBadgeDrag(e.touches[0].clientX, e.touches[0].clientY)
}
}
// ── 对话窗口拖拽逻辑 ──────────────────────────────────────────────────────────
let isDraggingWin = false
let winStartX = 0
let winStartY = 0
let winPointerStartX = 0
let winPointerStartY = 0
const startWindowDrag = (clientX: number, clientY: number) => {
isDraggingWin = true
winPointerStartX = clientX
winPointerStartY = clientY
winStartX = winPos.value.x
winStartY = winPos.value.y
const onMove = (e: MouseEvent | TouchEvent) => {
if (!isDraggingWin) return
const curX = 'touches' in e ? e.touches?.[0]?.clientX : e.clientX
const curY = 'touches' in e ? e.touches?.[0]?.clientY : e.clientY
if (curX === undefined || curY === undefined) return
const dx = curX - winPointerStartX
const dy = curY - winPointerStartY
const winW = window.innerWidth
const winH = window.innerHeight
const dialogW = isMinimized.value ? 320 : 390
const dialogH = isMinimized.value ? 52 : 580
aiStore.setWinPos({
x: Math.max(8, Math.min(winW - dialogW - 8, winStartX + dx)),
y: Math.max(8, Math.min(winH - dialogH - 8, winStartY + dy))
})
}
const onEnd = () => {
isDraggingWin = false
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onEnd)
window.removeEventListener('touchmove', onMove)
window.removeEventListener('touchend', onEnd)
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onEnd)
window.addEventListener('touchmove', onMove, { passive: false })
window.addEventListener('touchend', onEnd)
}
const handleWindowMouseDown = (e: MouseEvent) => {
if (e.button !== 0) return
startWindowDrag(e.clientX, e.clientY)
}
const handleWindowTouchStart = (e: TouchEvent) => {
if (e.touches && e.touches.length > 0) {
startWindowDrag(e.touches[0].clientX, e.touches[0].clientY)
}
}
const toggleMinimize = () => {
aiStore.toggleMinimize()
handleResize()
}
// ── 聊天滚动管理 ────────────────────────────────────────────────────────────
let scrollScheduled = false
const scrollToBottom = (smooth = true) => {
if (scrollScheduled) return
scrollScheduled = true
requestAnimationFrame(() => {
scrollScheduled = false
if (chatScrollRef.value) {
if (smooth) {
chatScrollRef.value.scrollTo({
top: chatScrollRef.value.scrollHeight,
behavior: 'smooth'
})
} else {
chatScrollRef.value.scrollTop = chatScrollRef.value.scrollHeight
}
}
})
}
const handleToggleOpen = () => {
aiStore.setOpen(true)
isMinimized.value = false
handleResize()
// 打开窗口时,自动滚动到底部最新内容并聚焦输入框
nextTick(() => {
scrollToBottom(false)
inputRef.value?.focus()
})
setTimeout(() => scrollToBottom(false), 60)
setTimeout(() => scrollToBottom(false), 200)
}
const dialogCount = computed(() => {
return messageList.value.filter((m) => m.sender === 'user').length
})
const handleNewChat = () => {
skipStreaming()
isTyping.value = false
suggestions.value = []
aiStore.clearMessages()
window.$message?.success('已为您开启全新对话 🔄')
// 点击新对话时:不需要滚动到底部,直接重置并停留在最顶部欢迎语
nextTick(() => {
if (chatScrollRef.value) {
chatScrollRef.value.scrollTop = 0
}
})
}
const handleClearChat = () => {
handleNewChat()
}
const handleSelectCategory = (catKey: FaqCategory) => {
skipStreaming()
aiStore.setActiveCategory(catKey)
const faqs = getFaqsByCategory(catKey)
const categoryName = FAQ_CATEGORIES.find((c) => c.key === catKey)?.label || '问题列表'
aiStore.addMessage({
id: `msg_cat_${crypto.randomUUID()}`,
sender: 'bot',
timestamp: now(),
content: `已为您筛选【${categoryName}】相关的常见问题:`,
matchedFaqs: faqs.slice(0, 6)
})
scrollToBottom()
}
const getRelatedFaq = (id: string) => {
return getFaqById(id)
}
let inputTimer: ReturnType<typeof setTimeout> | null = null
const handleInput = () => {
if (inputTimer) clearTimeout(inputTimer)
inputTimer = setTimeout(() => {
suggestions.value = getSuggestions(inputQuery.value, 4)
}, 120)
}
const handleSelectSuggestion = (item: FaqItem) => {
suggestions.value = []
inputQuery.value = item.title
handleSend()
}
const handleFeedback = (msg: ChatMessage, type: 'helpful' | 'unhelpful') => {
aiStore.updateMessageFeedback(msg.id, type)
if (type === 'helpful') {
window.$message?.success('感谢您的反馈,很高兴帮到了您!🎉')
} else {
window.$message?.info('收到反馈,我们会持续完善该题库内容。')
}
}
// ── requestAnimationFrame 极致丝滑打字机 ────────────────────────────────────
let streamRafId: number | null = null
let fullStreamingAnswer = ''
const finishStreaming = () => {
if (streamRafId) {
cancelAnimationFrame(streamRafId)
streamRafId = null
}
if (streamingMsg.value) {
const finalMsg: ChatMessage = {
...streamingMsg.value,
content: fullStreamingAnswer
}
aiStore.addMessage(finalMsg)
streamingMsg.value = null
streamingContent.value = ''
}
isStreaming.value = false
scrollToBottom(true)
}
const startStreaming = (msgTemplate: ChatMessage, fullText: string) => {
if (streamRafId) {
cancelAnimationFrame(streamRafId)
streamRafId = null
}
fullStreamingAnswer = fullText
streamingMsg.value = msgTemplate
streamingContent.value = ''
isStreaming.value = true
const startTime = performance.now()
// 匀速流式打字速度:每毫秒约 0.045 字符(即约 45 字符/秒,节奏自然舒适)
const msPerChar = 22
const tick = (nowTime: number) => {
const elapsed = nowTime - startTime
const targetCharCount = Math.min(fullText.length, Math.floor(elapsed / msPerChar))
if (targetCharCount > streamingContent.value.length) {
streamingContent.value = fullText.slice(0, targetCharCount)
scrollToBottom(true)
}
if (targetCharCount >= fullText.length) {
finishStreaming()
} else {
streamRafId = requestAnimationFrame(tick)
}
}
streamRafId = requestAnimationFrame(tick)
}
const skipStreaming = () => {
if (isStreaming.value) {
finishStreaming()
}
}
const handleSend = () => {
const query = inputQuery.value.trim()
if (!query) return
skipStreaming()
suggestions.value = []
inputQuery.value = ''
// 1. 用户提问入队
aiStore.addMessage({
id: `msg_u_${crypto.randomUUID()}`,
sender: 'user',
timestamp: now(),
content: query
})
scrollToBottom(true)
// 2. 模拟思考延时并启动丝滑流式输出
isTyping.value = true
setTimeout(() => {
isTyping.value = false
const result = matchFaq(query)
if (result.type === 'exact' && result.bestMatch) {
const pendingMsg: ChatMessage = {
id: `msg_b_${crypto.randomUUID()}`,
sender: 'bot',
timestamp: now(),
content: '',
faq: result.bestMatch
}
startStreaming(pendingMsg, result.bestMatch.answer)
} else if (result.type === 'candidates' && result.candidates.length > 0) {
const pendingMsg: ChatMessage = {
id: `msg_b_${crypto.randomUUID()}`,
sender: 'bot',
timestamp: now(),
content: '',
matchedFaqs: result.candidates
}
startStreaming(pendingMsg, '为您匹配到以下可能相关的系统功能问答:')
} else {
const answer =
'抱歉,小助手暂未检索到完全匹配的答案。您可以尝试输入拼音或关键词(如“bg”、“ft”、“fy”、“暂存”等),或查阅以下热点问题:'
const pendingMsg: ChatMessage = {
id: `msg_b_${crypto.randomUUID()}`,
sender: 'bot',
timestamp: now(),
content: '',
matchedFaqs: HOT_QUESTIONS.slice(0, 5)
}
startStreaming(pendingMsg, answer)
}
}, 180)
}
const handleAskQuestion = (title: string) => {
inputQuery.value = title
handleSend()
}
const handleTriggerAction = (action: FaqAction) => {
if (action.type === 'event') {
eventBus.emit(action.payload)
window.$message?.success(`已为您触发【${action.label}】`)
} else if (action.type === 'copy') {
if (navigator.clipboard) {
navigator.clipboard.writeText(action.payload)
window.$message?.success(`已复制到剪贴板:${action.label}`)
}
} else if (action.type === 'link') {
window.open(action.payload, '_blank')
}
}
// 复制回答内容到剪贴板
const handleCopyMessage = (msg: ChatMessage) => {
let textToCopy = msg.content
if (msg.faq) {
textToCopy = `【${msg.faq.title}】\n${msg.faq.answer}`
if (msg.faq.steps && msg.faq.steps.length > 0) {
textToCopy += `\n\n操作指引:\n` + msg.faq.steps.map((s, idx) => `${idx + 1}. ${s}`).join('\n')
}
if (msg.faq.highlights && msg.faq.highlights.length > 0) {
textToCopy += `\n\n提示:\n` + msg.faq.highlights.join('\n')
}
}
if (navigator.clipboard) {
navigator.clipboard.writeText(textToCopy)
window.$message?.success('已复制回答内容到剪贴板 📋')
}
}
onMounted(() => {
initPositions()
window.addEventListener('resize', handleResize)
// 每次进入编辑器挂载时:若窗口处于展开状态,自动将历史对话滚动到底部最新内容
if (isOpen.value) {
nextTick(() => scrollToBottom(false))
setTimeout(() => scrollToBottom(false), 80)
setTimeout(() => scrollToBottom(false), 250)
}
})
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize)
if (inputTimer) clearTimeout(inputTimer)
if (streamRafId) cancelAnimationFrame(streamRafId)
})
return {
themeVars,
isOpen,
isMinimized,
unreadCount,
activeCategory,
inputQuery,
isTyping,
isStreaming,
streamingContent,
streamingMsg,
chatScrollRef,
inputRef,
suggestions,
badgeRef,
windowRef,
badgePos,
winPos,
isBadgeSnapping,
isInitialized,
messageList,
contextFaqs,
dialogCount,
selectedNodeTag: computed(() => editorStore.selectedNode?.tagName),
FAQ_CATEGORIES,
handleBadgeMouseDown,
handleBadgeTouchStart,
handleWindowMouseDown,
handleWindowTouchStart,
toggleMinimize,
scrollToBottom,
handleToggleOpen,
handleClearChat,
handleNewChat,
handleSelectCategory,
getRelatedFaq,
handleInput,
handleSelectSuggestion,
handleFeedback,
handleSend,
handleAskQuestion,
handleTriggerAction,
handleCopyMessage,
skipStreaming
}
}
<template>
<div class="ai-assistant-container select-none">
<!-- 1. 可自由拖动并支持磁力贴边的悬浮客服入口按钮 -->
<transition name="bot-badge-fade">
<div
v-if="!isOpen"
ref="badgeRef"
class="fixed z-40 flex items-center flex-nowrap whitespace-nowrap group cursor-grab active:cursor-grabbing select-none"
:class="[isBadgeSnapping ? 'transition-all duration-300 ease-out' : '']"
:style="{
left: `${badgePos.x}px`,
top: `${badgePos.y}px`,
touchAction: 'none'
}"
@mousedown="handleBadgeMouseDown"
@touchstart.passive="handleBadgeTouchStart"
>
<!-- 悬浮小气泡提示语(强制单行不换行) -->
<div
class="mr-3 px-3 py-1.5 rounded-full bg-card border border-divider shadow-lg text-xs font-medium text-color1 flex items-center gap-1.5 whitespace-nowrap shrink-0 opacity-90 group-hover:opacity-100 group-hover:scale-105 transition-all duration-200 pointer-events-none"
:style="{
borderColor: `color-mix(in srgb, ${themeVars.primaryColor} 30%, transparent)`,
boxShadow: `0 4px 16px ${themeVars.primaryColor}20`
}"
>
<span class="inline-block w-2 h-2 rounded-full bg-success animate-pulse shrink-0"></span>
<span class="whitespace-nowrap shrink-0 font-medium">智能工卡助手</span>
<span class="text-[10px] text-color3 whitespace-nowrap shrink-0">磁力吸边 · 点我提问</span>
</div>
<!-- 悬浮主头像球 -->
<div
class="relative w-13 h-13 rounded-full flex items-center justify-center text-white shadow-xl transition-transform duration-200 transform group-hover:scale-110 active:scale-95 shrink-0"
:style="{
background: `linear-gradient(135deg, ${themeVars.primaryColor}, var(--n-primary-color-hover, ${themeVars.primaryColor}))`,
boxShadow: `0 8px 24px color-mix(in srgb, ${themeVars.primaryColor} 45%, transparent)`
}"
>
<n-icon size="26" class="animate-bounce-subtle pointer-events-none">
<chatbubble-ellipses-outline />
</n-icon>
<!-- 呼吸波纹动画圈 -->
<div
class="absolute inset-0 rounded-full animate-ping-slow pointer-events-none"
:style="{
borderColor: themeVars.primaryColor,
borderWidth: '2px',
borderStyle: 'solid',
opacity: 0.35
}"
></div>
<!-- 未读小红点 -->
<div
v-if="unreadCount > 0"
class="absolute -top-1 -right-1 w-5 h-5 bg-error text-white text-[10px] font-bold rounded-full flex items-center justify-center border-2 border-card shadow-sm"
>
{{ unreadCount }}
</div>
</div>
</div>
</transition>
<!-- 2. 可自由拖动的客服对话窗口 -->
<transition name="bot-panel-spring">
<div
v-if="isOpen"
ref="windowRef"
class="fixed z-50 flex flex-col rounded-2xl border border-divider bg-card shadow-2xl overflow-hidden text-color1"
:class="[isMinimized ? 'h-[52px] w-[320px]' : 'h-[580px] w-[390px] max-h-[calc(100vh-32px)] max-w-[calc(100vw-32px)]']"
:style="{
left: `${winPos.x}px`,
top: `${winPos.y}px`,
boxShadow: `0 12px 36px rgba(0, 0, 0, 0.18), 0 0 1px ${themeVars.primaryColor}40`
}"
>
<!-- 头部工具栏(按住可拖动窗口) -->
<div
class="h-[52px] px-4 flex items-center justify-between border-b border-divider shrink-0 text-white cursor-grab active:cursor-grabbing select-none"
:style="{
background: `linear-gradient(135deg, ${themeVars.primaryColor}, var(--n-primary-color-pressed, ${themeVars.primaryColor}))`
}"
@mousedown="handleWindowMouseDown"
@touchstart.passive="handleWindowTouchStart"
>
<!-- 智能助手信息 -->
<div class="flex items-center gap-2.5 pointer-events-none">
<div
class="w-8 h-8 rounded-full bg-white/20 backdrop-blur flex items-center justify-center text-white border border-white/30"
>
<n-icon size="18"><sparkles-outline /></n-icon>
</div>
<div class="flex flex-col">
<div class="text-sm font-bold leading-tight flex items-center gap-1.5">
<span>工卡智能助手</span>
<span class="text-[9px] bg-white/25 px-1.5 py-0.2 rounded font-normal">AI Copilot</span>
</div>
<div class="text-[11px] text-white/80 leading-tight flex items-center gap-1 mt-0.5">
<span class="w-1.5 h-1.5 rounded-full bg-success"></span>
<span>全功能掌握 · 实时在线</span>
</div>
</div>
</div>
<!-- 头部操作按钮(阻止拖动事件冒泡) -->
<div class="flex items-center gap-1" @mousedown.stop @touchstart.stop>
<!-- 开启新对话按钮 -->
<n-tooltip trigger="hover" placement="bottom">
<template #trigger>
<button
type="button"
class="px-2 py-1 rounded-lg bg-white/15 hover:bg-white/25 active:bg-white/35 text-white flex items-center gap-1 text-[11px] font-medium transition-all focus:outline-none shadow-xs"
@click="handleNewChat"
>
<n-icon size="13"><add-outline /></n-icon>
<span>新对话</span>
</button>
</template>
开启全新会话(自动保留最近 4 轮问答)
</n-tooltip>
<n-tooltip trigger="hover" placement="bottom">
<template #trigger>
<button
type="button"
class="w-7 h-7 rounded-lg hover:bg-white/20 active:bg-white/30 text-white/90 hover:text-white flex items-center justify-center transition-colors focus:outline-none"
@click="toggleMinimize"
>
<n-icon size="15"><remove-outline /></n-icon>
</button>
</template>
{{ isMinimized ? '还原窗口' : '最小化' }}
</n-tooltip>
<button
type="button"
class="w-7 h-7 rounded-lg hover:bg-white/20 active:bg-white/30 text-white/90 hover:text-white flex items-center justify-center transition-colors focus:outline-none"
@click="isOpen = false"
>
<n-icon size="17"><close-outline /></n-icon>
</button>
</div>
</div>
<!-- 窗口主体内容(非最小化时) -->
<div v-show="!isMinimized" class="flex-1 flex flex-col min-h-0 bg-fill-1/40">
<!-- 分类导航胶囊条 -->
<div class="px-3 py-2 border-b border-divider bg-card/60 flex items-center gap-1.5 overflow-x-auto no-scrollbar shrink-0">
<button
v-for="cat in FAQ_CATEGORIES"
:key="cat.key"
type="button"
class="px-2.5 py-1 text-[11px] rounded-full whitespace-nowrap transition-all font-medium focus:outline-none shrink-0"
:class="activeCategory === cat.key ? 'text-white' : 'text-color2 bg-fill-2 hover:bg-fill-3'"
:style="
activeCategory === cat.key
? {
backgroundColor: themeVars.primaryColor,
boxShadow: `0 2px 6px ${themeVars.primaryColor}40`
}
: {}
"
@click="handleSelectCategory(cat.key)"
>
{{ cat.label }}
</button>
</div>
<!-- 当前选中节点上下文感知专属建议栏 -->
<div
v-if="selectedNodeTag && contextFaqs.length > 0"
class="px-3 py-1.5 bg-primary/10 border-b border-divider flex items-center justify-between gap-2 shrink-0 text-xs"
>
<div class="flex items-center gap-1.5 truncate text-primary font-medium text-[11px]">
<n-icon class="shrink-0"><flash-outline /></n-icon>
<span class="truncate">当前选中 &lt;{{ selectedNodeTag }}&gt; 专属指南:</span>
</div>
<div class="flex items-center gap-1 shrink-0">
<button
v-for="cFaq in contextFaqs"
:key="cFaq.id"
type="button"
class="px-2 py-0.5 text-[10px] rounded bg-card border border-divider text-color2 hover:text-primary hover:border-primary transition-colors focus:outline-none"
@click="handleAskQuestion(cFaq.title)"
>
{{ cFaq.title.length > 8 ? cFaq.title.slice(0, 8) + '…' : cFaq.title }}
</button>
</div>
</div>
<!-- 消息流滚动区域 -->
<div ref="chatScrollRef" class="flex-1 p-3.5 space-y-4 overflow-y-auto chat-scroll-area">
<!-- 对话轮数提示与快捷清空 -->
<div
v-if="dialogCount > 0"
class="flex items-center justify-between px-2.5 py-1 rounded-lg bg-fill-2/50 text-[10px] text-color3 border border-divider/40"
>
<span>已保留最近 {{ dialogCount }} 轮对话(超出 4 轮自动修剪)</span>
<button type="button" class="text-primary hover:underline font-medium focus:outline-none" @click="handleNewChat">
清空开启新对话
</button>
</div>
<!-- 遍历历史已完成消息 -->
<template v-for="msg in messageList" :key="msg.id">
<!-- 用户提问气泡 -->
<div v-if="msg.sender === 'user'" class="flex items-start justify-end gap-2">
<div
class="max-w-[82%] px-3.5 py-2.5 rounded-2xl rounded-tr-sm text-xs leading-relaxed shadow-sm text-white break-words"
:style="{
backgroundColor: themeVars.primaryColor,
boxShadow: `0 2px 8px ${themeVars.primaryColor}30`
}"
>
{{ msg.content }}
</div>
<div
class="w-7 h-7 rounded-full bg-primary/20 flex items-center justify-center text-primary text-xs font-bold shrink-0"
>
</div>
</div>
<!-- 客服历史已完成回复卡片 -->
<div v-else class="flex items-start gap-2.5">
<div
class="w-7 h-7 rounded-full flex items-center justify-center text-white text-xs shrink-0 shadow-sm mt-0.5"
:style="{
background: `linear-gradient(135deg, ${themeVars.primaryColor}, var(--n-primary-color-hover, ${themeVars.primaryColor}))`
}"
>
<n-icon size="14"><sparkles-outline /></n-icon>
</div>
<div class="flex-1 max-w-[88%] space-y-2">
<!-- 标准解答卡片 -->
<div
class="p-3 rounded-2xl rounded-tl-sm bg-card border border-divider text-xs text-color1 shadow-sm space-y-2 leading-relaxed"
>
<!-- 分类、标题与复制按钮 -->
<div v-if="msg.faq" class="pb-1.5 border-b border-divider flex items-center justify-between">
<span class="font-bold text-primary flex items-center gap-1 truncate pr-1">
<n-icon class="text-sm shrink-0"><help-circle-outline /></n-icon>
<span class="truncate">{{ msg.faq.title }}</span>
</span>
<div class="flex items-center gap-1.5 shrink-0">
<span class="text-[10px] px-1.5 py-0.5 rounded bg-fill-2 text-color3 font-mono">
{{ msg.faq.categoryName }}
</span>
<n-tooltip trigger="hover">
<template #trigger>
<button
type="button"
class="text-color3 hover:text-primary transition-colors p-0.5 rounded focus:outline-none"
@click="handleCopyMessage(msg)"
>
<n-icon size="14"><copy-outline /></n-icon>
</button>
</template>
复制回答
</n-tooltip>
</div>
</div>
<!-- 正文描述 -->
<div class="text-color2 whitespace-pre-line leading-relaxed">
{{ msg.content }}
</div>
<!-- 步骤列表 -->
<div
v-if="msg.faq?.steps && msg.faq.steps.length > 0"
class="space-y-1.5 bg-fill-1 p-2.5 rounded-lg border border-divider/60"
>
<div class="text-[11px] font-bold text-color1 flex items-center gap-1">
<span>📋 操作指引:</span>
</div>
<div
v-for="(step, sIdx) in msg.faq.steps"
:key="sIdx"
class="text-[11px] text-color2 flex items-start gap-1.5 leading-normal"
>
<span class="text-primary font-bold shrink-0">{{ sIdx + 1 }}.</span>
<span class="flex-1">{{ step }}</span>
</div>
</div>
<!-- 重点提示 -->
<div
v-if="msg.faq?.highlights && msg.faq.highlights.length > 0"
class="p-2 rounded bg-warning/10 border border-warning/20 text-[11px] text-warning space-y-0.5"
>
<div v-for="(h, hIdx) in msg.faq.highlights" :key="hIdx" class="flex items-start gap-1">
<span class="font-bold">💡 提示:</span>
<span>{{ h }}</span>
</div>
</div>
<!-- 快捷操作按钮 -->
<div v-if="msg.faq?.actions && msg.faq.actions.length > 0" class="pt-1 flex flex-wrap gap-1.5">
<button
v-for="(act, aIdx) in msg.faq.actions"
:key="aIdx"
type="button"
class="px-2.5 py-1 rounded text-xs font-semibold bg-primary text-white hover:bg-primary-hover active:bg-primary-pressed transition-all shadow-sm hover:shadow flex items-center gap-1 focus:outline-none"
@click="handleTriggerAction(act)"
>
<n-icon><arrow-forward-outline /></n-icon>
<span>{{ act.label }}</span>
</button>
</div>
</div>
<!-- 多候选问题(猜你想问) -->
<div
v-if="msg.matchedFaqs && msg.matchedFaqs.length > 0"
class="p-2.5 rounded-xl bg-card border border-divider space-y-1.5 shadow-sm"
>
<div class="text-[11px] font-semibold text-color3">您是不是想了解:</div>
<div class="space-y-1">
<button
v-for="item in msg.matchedFaqs"
:key="item.id"
type="button"
class="w-full text-left px-2 py-1.5 rounded-lg text-xs text-primary hover:bg-primary-1 hover:text-primary-pressed transition-colors flex items-center justify-between group focus:outline-none"
@click="handleAskQuestion(item.title)"
>
<span class="truncate flex-1">🔹 {{ item.title }}</span>
<n-icon class="text-color3 group-hover:text-primary transition-colors shrink-0 ml-1">
<chevron-forward-outline />
</n-icon>
</button>
</div>
</div>
<!-- 相关推荐问题 -->
<div
v-if="msg.faq?.relatedQuestions && msg.faq.relatedQuestions.length > 0"
class="p-2.5 rounded-xl bg-fill-2/50 border border-divider/60 space-y-1.5"
>
<div class="text-[11px] font-semibold text-color3 flex items-center gap-1">
<span>猜你想继续了解:</span>
</div>
<div class="flex flex-wrap gap-1">
<template v-for="rId in msg.faq.relatedQuestions" :key="rId">
<button
v-if="getRelatedFaq(rId)"
type="button"
class="px-2 py-1 rounded-md text-[11px] bg-card border border-divider text-color2 hover:text-primary hover:border-primary transition-all focus:outline-none"
@click="handleAskQuestion(getRelatedFaq(rId)!.title)"
>
{{ getRelatedFaq(rId)!.title }}
</button>
</template>
</div>
</div>
<!-- 满意度反馈 -->
<div v-if="msg.faq" class="flex items-center justify-between text-[11px] text-color3 pt-0.5">
<button
type="button"
class="hover:text-primary transition-colors flex items-center gap-1 focus:outline-none"
@click="handleCopyMessage(msg)"
>
<n-icon size="12"><copy-outline /></n-icon>
<span>复制</span>
</button>
<div class="flex items-center gap-1.5">
<span class="text-[10px]">有解答疑惑吗?</span>
<button
type="button"
class="flex items-center gap-1 px-1.5 py-0.5 rounded border border-divider hover:text-primary hover:border-primary transition-colors focus:outline-none"
:class="{ 'text-primary border-primary font-bold bg-primary-1': msg.feedback === 'helpful' }"
@click="handleFeedback(msg, 'helpful')"
>
<n-icon><thumbs-up-outline /></n-icon>
<span>有帮助</span>
</button>
<button
type="button"
class="flex items-center gap-1 px-1.5 py-0.5 rounded border border-divider hover:text-error hover:border-error transition-colors focus:outline-none"
:class="{
'text-error border-error font-bold bg-error/10': msg.feedback === 'unhelpful'
}"
@click="handleFeedback(msg, 'unhelpful')"
>
<n-icon><thumbs-down-outline /></n-icon>
<span>没帮助</span>
</button>
</div>
</div>
</div>
</div>
</template>
<!-- 当前正在实时流式渲染的新回复卡片(完全与 Pinia 解耦,0 抖动) -->
<div v-if="streamingMsg" class="flex items-start gap-2.5">
<div
class="w-7 h-7 rounded-full flex items-center justify-center text-white text-xs shrink-0 shadow-sm mt-0.5"
:style="{
background: `linear-gradient(135deg, ${themeVars.primaryColor}, var(--n-primary-color-hover, ${themeVars.primaryColor}))`
}"
>
<n-icon size="14"><sparkles-outline /></n-icon>
</div>
<div class="flex-1 max-w-[88%] space-y-2">
<div
class="p-3 rounded-2xl rounded-tl-sm bg-card border border-divider text-xs text-color1 shadow-sm space-y-2 leading-relaxed"
>
<div v-if="streamingMsg.faq" class="pb-1.5 border-b border-divider flex items-center justify-between">
<span class="font-bold text-primary flex items-center gap-1 truncate pr-1">
<n-icon class="text-sm shrink-0"><help-circle-outline /></n-icon>
<span class="truncate">{{ streamingMsg.faq.title }}</span>
</span>
<span class="text-[10px] px-1.5 py-0.5 rounded bg-fill-2 text-color3 font-mono shrink-0">
{{ streamingMsg.faq.categoryName }}
</span>
</div>
<!-- 实时流式文字与科技打字光标 -->
<div class="text-color2 whitespace-pre-line leading-relaxed">
<span>{{ streamingContent }}</span>
<span
class="inline-block w-1.5 h-3.5 ml-0.5 rounded-xs align-middle ai-typing-cursor"
:style="{ backgroundColor: themeVars.primaryColor }"
></span>
</div>
</div>
</div>
</div>
<!-- 机器人正在思考打字动效 -->
<transition name="typing-fade">
<div v-if="isTyping" class="flex items-start gap-2">
<div class="w-7 h-7 rounded-full bg-primary/30 flex items-center justify-center text-primary text-xs shrink-0">
<sparkles-outline />
</div>
<div class="px-3.5 py-2.5 rounded-2xl bg-card border border-divider flex items-center gap-1.5 shadow-sm">
<span class="w-1.5 h-1.5 rounded-full bg-primary animate-dot-wave"></span>
<span class="w-1.5 h-1.5 rounded-full bg-primary animate-dot-wave [animation-delay:0.18s]"></span>
<span class="w-1.5 h-1.5 rounded-full bg-primary animate-dot-wave [animation-delay:0.36s]"></span>
</div>
</div>
</transition>
</div>
<!-- 输入自动联想弹窗 -->
<div
v-if="suggestions.length > 0 && inputQuery.trim()"
class="mx-3 mb-1 p-1 rounded-xl bg-card border border-divider shadow-lg space-y-0.5 shrink-0"
>
<div class="text-[10px] text-color3 px-2 py-0.5 font-medium flex items-center justify-between">
<span>🔍 智能匹配建议:</span>
<span class="text-[9px] text-color3/80">支持拼音首字母</span>
</div>
<button
v-for="sug in suggestions"
:key="sug.id"
type="button"
class="w-full text-left px-2 py-1 rounded text-xs text-color2 hover:bg-fill-2 hover:text-primary transition-colors flex items-center justify-between focus:outline-none"
@click="handleSelectSuggestion(sug)"
>
<span class="truncate">{{ sug.title }}</span>
<span class="text-[10px] text-color3 font-mono">{{ sug.categoryName }}</span>
</button>
</div>
<!-- 底部输入栏 -->
<div class="p-3 border-t border-divider bg-card shrink-0 space-y-2">
<div class="flex items-center gap-2">
<div class="relative flex-1">
<input
ref="inputRef"
v-model="inputQuery"
type="text"
placeholder="输入问题或简拼(如 bg, ft, fy, 导出...)"
class="w-full h-9 pl-3 pr-8 rounded-xl bg-fill-2 text-xs text-color1 placeholder:text-color3 border border-transparent focus:border-primary focus:bg-card focus:outline-none transition-all"
@keydown.enter.prevent="handleSend"
@input="handleInput"
/>
<button
v-if="inputQuery"
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 text-color3 hover:text-color1 focus:outline-none"
@click="inputQuery = ''"
>
<n-icon size="14"><close-outline /></n-icon>
</button>
</div>
<button
type="button"
class="h-9 px-3.5 rounded-xl bg-primary text-white text-xs font-semibold flex items-center justify-center gap-1 hover:bg-primary-hover active:bg-primary-pressed disabled:opacity-40 disabled:hover:bg-primary transition-all focus:outline-none shadow-sm"
:disabled="!inputQuery.trim() || isTyping"
@click="handleSend"
>
<span>发送</span>
<n-icon size="14"><send-outline /></n-icon>
</button>
</div>
<!-- 底部功能指引与快捷指令 -->
<div class="flex items-center justify-between text-[10px] text-color3 px-1">
<button
v-if="isStreaming"
type="button"
class="text-primary font-medium hover:underline focus:outline-none flex items-center gap-0.5"
@click="skipStreaming"
>
<span>⏩ 点击跳过打字</span>
</button>
<span v-else>按 Enter 发送 / 支持拼音简拼</span>
<button
type="button"
class="hover:text-primary transition-colors focus:outline-none"
@click="handleAskQuestion('工卡 XML 编辑器有哪些常用快捷键?')"
>
⌨️ 快捷键说明
</button>
</div>
</div>
</div>
</div>
</transition>
</div>
</template>
<script setup lang="ts">
import {
ChatbubbleEllipsesOutline,
CloseOutline,
SendOutline,
SparklesOutline,
TrashOutline,
RemoveOutline,
HelpCircleOutline,
ChevronForwardOutline,
ThumbsUpOutline,
ThumbsDownOutline,
ArrowForwardOutline,
FlashOutline,
CopyOutline,
AddOutline
} from '@vicons/ionicons5'
import { useAiAssistant } from './functionals'
const {
themeVars,
isOpen,
isMinimized,
unreadCount,
activeCategory,
inputQuery,
isTyping,
isStreaming,
streamingContent,
streamingMsg,
chatScrollRef,
inputRef,
suggestions,
badgeRef,
windowRef,
badgePos,
winPos,
isBadgeSnapping,
messageList,
contextFaqs,
dialogCount,
selectedNodeTag,
FAQ_CATEGORIES,
handleBadgeMouseDown,
handleBadgeTouchStart,
handleWindowMouseDown,
handleWindowTouchStart,
toggleMinimize,
handleClearChat,
handleNewChat,
handleSelectCategory,
getRelatedFaq,
handleInput,
handleSelectSuggestion,
handleFeedback,
handleSend,
handleAskQuestion,
handleTriggerAction,
handleCopyMessage,
skipStreaming
} = useAiAssistant()
</script>
<style scoped>
.chat-scroll-area {
scrollbar-width: thin;
scrollbar-color: var(--colorFill4, rgba(128, 128, 128, 0.2)) transparent;
}
.chat-scroll-area::-webkit-scrollbar {
width: 5px;
}
.chat-scroll-area::-webkit-scrollbar-thumb {
background: var(--colorFill4, rgba(128, 128, 128, 0.2));
border-radius: 3px;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* 打字机呼吸光标 */
.ai-typing-cursor {
animation: cursorBlink 0.8s infinite ease-in-out;
}
@keyframes cursorBlink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.15;
}
}
/* 思考等待波纹动画 */
.animate-dot-wave {
animation: dotWave 1.4s infinite ease-in-out both;
}
@keyframes dotWave {
0%,
80%,
100% {
transform: scale(0.6);
opacity: 0.4;
}
40% {
transform: scale(1.15);
opacity: 1;
}
}
.typing-fade-enter-active,
.typing-fade-leave-active {
transition: opacity 0.2s ease;
}
.typing-fade-enter-from,
.typing-fade-leave-to {
opacity: 0;
}
/* 悬浮微动画 */
.animate-bounce-subtle {
animation: bounceSubtle 2s infinite ease-in-out;
}
@keyframes bounceSubtle {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-3px);
}
}
.animate-ping-slow {
animation: pingSlow 3s cubic-bezier(0, 0, 0.2, 1) infinite;
}
@keyframes pingSlow {
0% {
transform: scale(0.95);
opacity: 0.8;
}
70%,
100% {
transform: scale(1.4);
opacity: 0;
}
}
/* 入口淡入淡出 */
.bot-badge-fade-enter-active,
.bot-badge-fade-leave-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.bot-badge-fade-enter-from,
.bot-badge-fade-leave-to {
opacity: 0;
transform: scale(0.8);
}
/* 面板展开弹性动画 */
.bot-panel-spring-enter-active {
transition: all 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.bot-panel-spring-leave-active {
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.bot-panel-spring-enter-from,
.bot-panel-spring-leave-to {
opacity: 0;
transform: scale(0.9);
}
</style>
import type { AiAssistantState, Position } from './types'
import type { ChatMessage, FaqCategory } from '@/layouts/components/AiAssistant/constants'
import { DEFAULT_WELCOME_MESSAGE, HOT_QUESTIONS } from '@/layouts/components/AiAssistant/constants'
// 最大保留问答条数(约 4 轮问答对,超出自动修剪)
const MAX_HISTORY_MESSAGES = 8
export const useAiAssistantStore = defineStore('aiAssistant', {
state: (): AiAssistantState => ({
isOpen: false,
isMinimized: false,
unreadCount: 1,
activeCategory: 'all',
badgePos: { x: 0, y: 0 },
winPos: { x: 0, y: 0 },
messageList: [
{
id: 'msg_welcome',
sender: 'bot',
timestamp: now(),
content: DEFAULT_WELCOME_MESSAGE,
matchedFaqs: HOT_QUESTIONS
}
]
}),
actions: {
setOpen(open: boolean) {
this.isOpen = open
if (open) {
this.unreadCount = 0
}
},
toggleMinimize() {
this.isMinimized = !this.isMinimized
},
setBadgePos(pos: Position) {
this.badgePos = pos
},
setWinPos(pos: Position) {
this.winPos = pos
},
setActiveCategory(category: FaqCategory) {
this.activeCategory = category
},
addMessage(msg: ChatMessage) {
this.messageList.push(msg)
// 自动修剪机制:保留欢迎语,其余对话最多保留最近 8 条(4 轮)
const nonWelcome = this.messageList.filter((m) => m.id !== 'msg_welcome')
if (nonWelcome.length > MAX_HISTORY_MESSAGES) {
const welcomeMsg = this.messageList.find((m) => m.id === 'msg_welcome')
const pruned = nonWelcome.slice(-MAX_HISTORY_MESSAGES)
this.messageList = welcomeMsg ? [welcomeMsg, ...pruned] : pruned
}
},
updateMessageFeedback(msgId: string, feedback: 'helpful' | 'unhelpful') {
const found = this.messageList.find((m) => m.id === msgId)
if (found) {
found.feedback = feedback
}
},
clearMessages() {
this.messageList = [
{
id: 'msg_welcome',
sender: 'bot',
timestamp: now(),
content: DEFAULT_WELCOME_MESSAGE,
matchedFaqs: HOT_QUESTIONS
}
]
}
},
persist: true
})
export * from './types'
import type { ChatMessage, FaqCategory } from '@/layouts/components/AiAssistant/constants'
export interface Position {
x: number
y: number
}
export interface AiAssistantState {
isOpen: boolean
isMinimized: boolean
unreadCount: number
activeCategory: FaqCategory
badgePos: Position
winPos: Position
messageList: ChatMessage[]
}
...@@ -575,6 +575,29 @@ const { ...@@ -575,6 +575,29 @@ const {
const insertFragmentModalRef = ref<any>(null) const insertFragmentModalRef = ref<any>(null)
const compareModalRef = ref<any>(null) const compareModalRef = ref<any>(null)
const stashModalRef = ref<any>(null) const stashModalRef = ref<any>(null)
// 注册 AI 客服快捷动作监听
const eventBus = useEventBus()
onMounted(() => {
eventBus.on('trigger_insert_table', () => handleInsert('TABLE'))
eventBus.on('trigger_insert_graphic', () => handleInsert('GRAPHIC'))
eventBus.on('trigger_insert_signoff', () => handleInsert('SIGNOFF'))
eventBus.on('trigger_insert_selection', () => handleInsert('SELECTION'))
eventBus.on('trigger_insert_template', () => handleInsert('TEMPLATE'))
eventBus.on('trigger_insert_fragment', () => insertFragmentModalRef.value?.open(insertBelow.value))
eventBus.on('trigger_import_xml', () => triggerUpload())
eventBus.on('trigger_export_xml', () => emit('export'))
eventBus.on('trigger_preview', () => emit('preview'))
eventBus.on('trigger_download_html', () => emit('download-html'))
eventBus.on('trigger_stash', () => stashModalRef.value?.open())
eventBus.on('trigger_batch_translate', () => handleTranslate('batch'))
eventBus.on('trigger_extract_translate', () => handleTranslate('extract'))
eventBus.on('trigger_search_translate', () => handleTranslate('search'))
eventBus.on('trigger_compare', () => compareModalRef.value?.open())
eventBus.on('trigger_open_settings', () => {
appStore.settingsOpen = true
})
})
</script> </script>
<style scoped> <style scoped>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment