@@ -0,0 +1,382 @@
< template >
< div >
< div class = "ai-float-btn" @click ="visible = true" >
< span class = "ai-float-icon" > AI < / span >
< / div >
< el-dialog
v-model = "visible"
title = "TradeMate AI 助手"
width = "480px"
:close-on-click-modal = "false"
class = "ai-dialog"
top = "5vh"
@opened ="onOpened"
>
< div class = "ai-messages" ref = "msgContainer" >
< div v-for = "(msg, i) in messages" :key="i" class="ai-msg-row" :class="msg.role" >
< div class = "ai-avatar" v-if = "msg.role === 'assistant'" >
< el -icon :size = "18" color = "#667eea" > < Cpu / > < / el-icon >
< / div >
< div class = "ai-msg-body" >
< div class = "ai-msg-bubble" >
< div class = "ai-msg-text" > { { msg . content } } < / div >
< div v-if = "msg.actions && msg.actions.length" class="ai-action-card" >
< div v-for = "(action, ai) in msg.actions" :key="ai" class="ai-action-item" >
< div class = "ai-action-title" > { { action . label } } < / div >
< div v-for = "(val, key) in action.fields" :key="key" class="ai-field-row" >
< span class = "ai-field-label" > { { fieldLabel ( action . type , key ) } } < / span >
< el-input
v-model = "action.fields[key]"
: placeholder = "fieldPlaceholder(action.type, key)"
size = "small"
/ >
< / div >
< div class = "ai-action-btns" >
< el-button size = "small" @click ="cancelAction(i, ai)" > 取消 < / el -button >
< el-button size = "small" type = "primary" @click ="confirmAction(i, ai)" > 确认执行 < / el -button >
< / div >
< / div >
< / div >
< / div >
< / div >
< / div >
< div v-if = "loading" class="ai-loading" >
< el -icon class = "is-loading" :size = "18" > < Loading / > < / el-icon >
< span > 思考中 ... < / span >
< / div >
< div v-if = "showSuggestions" class="ai-suggestions" >
< div
v-for = "(s, i) in suggestions"
:key = "i"
class = "ai-suggestion"
@click ="sendQuick(s)"
>
{ { s } }
< / div >
< / div >
< / div >
< template # footer >
< div class = "ai-input-bar" >
< el-input
v-model = "inputText"
placeholder = "输入你的问题..."
:disabled = "loading"
size = "default"
@keyup.enter ="send"
/ >
< el-button type = "primary" : disabled = "!inputText.trim() || loading" @click ="send" > 发送 < / el -button >
< / div >
< / template >
< / el-dialog >
< / div >
< / template >
< script setup >
import { ref , nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { Cpu , Loading } from '@element-plus/icons-vue'
import {
aiChat , aiQuickQuestions ,
createCustomer , createProduct , createQuotation ,
scanFollowups , generateMarketing , discoverySearch ,
} from '@/api'
const visible = ref ( false )
const inputText = ref ( '' )
const loading = ref ( false )
const suggestions = ref ( [ ] )
const msgContainer = ref ( null )
const messages = ref ( [
{ role : 'assistant' , content : '你好!我是 TradeMate AI 助手,可以帮你操作外贸工具的各项功能。你可以说:"帮我添加一个客户"、"创建一个产品"、"生成报价单"等。' } ,
] )
const showSuggestions = ref ( false )
const fieldLabel = ( type , key ) => {
const labels = {
name : '名称' , phone : '电话' , email : '邮箱' , company : '公司' ,
country : '国家' , website : '网站' , notes : '备注' ,
name _en : '英文名称' , description : '描述' , description _en : '英文描述' ,
category : '分类' , price : '价格' , price _unit : '货币' , moq : 'MOQ' ,
keywords : '关键词' ,
customer _name : '客户名称' , product _info : '产品信息' , quantity : '数量' ,
terms : '交易条款' ,
message : '消息内容' ,
product _name : '产品名称' , target _market : '目标市场' , tone : '语气' ,
language : '语言' ,
industry : '行业' ,
}
return labels [ key ] || key
}
const fieldPlaceholder = ( type , key ) => {
const placeholders = {
phone : '如 +86-13800138000' ,
email : '如 contact@example.com' ,
price : '如 12.50' ,
price _unit : '默认 USD' ,
keywords : '逗号分隔' ,
quantity : '如 1000 pcs' ,
target _market : '如 美国、欧洲' ,
}
return placeholders [ key ] || ''
}
const cancelAction = ( msgIdx , actionIdx ) => {
const msg = messages . value [ msgIdx ]
if ( msg . actions ) msg . actions . splice ( actionIdx , 1 )
}
const confirmAction = async ( msgIdx , actionIdx ) => {
const action = messages . value [ msgIdx ] . actions [ actionIdx ]
const { type , fields } = action
loading . value = true
try {
switch ( type ) {
case 'create_customer' :
if ( ! fields . name ) { ElMessage . warning ( '客户名称不能为空' ) ; return }
await createCustomer ( fields )
break
case 'create_product' :
if ( ! fields . name ) { ElMessage . warning ( '产品名称不能为空' ) ; return }
if ( fields . keywords && typeof fields . keywords === 'string' ) {
fields . keywords = fields . keywords . split ( /[,, ]/ ) . map ( s => s . trim ( ) ) . filter ( Boolean )
}
await createProduct ( fields )
break
case 'create_quotation' :
if ( ! fields . customer _name ) { ElMessage . warning ( '客户名称不能为空' ) ; return }
if ( ! fields . product _info ) { ElMessage . warning ( '产品信息不能为空' ) ; return }
if ( ! fields . quantity ) { ElMessage . warning ( '数量不能为空' ) ; return }
await createQuotation ( {
customer _name : fields . customer _name ,
items : [ { description : fields . product _info , quantity : fields . quantity , price : fields . price || '0' } ] ,
terms : fields . terms || '' ,
} )
break
case 'scan_followups' :
await scanFollowups ( )
break
case 'generate_marketing' :
if ( ! fields . product _name ) { ElMessage . warning ( '产品名称不能为空' ) ; return }
await generateMarketing ( {
product _info : { name : fields . product _name } ,
target _market : fields . target _market ,
tone : fields . tone ,
language : fields . language ,
} )
break
case 'discovery_search' :
if ( ! fields . keywords ) { ElMessage . warning ( '关键词不能为空' ) ; return }
await discoverySearch ( {
keywords : fields . keywords . split ( /[,, ]/ ) . map ( s => s . trim ( ) ) . filter ( Boolean ) ,
country : fields . country ,
industry : fields . industry ,
} )
break
default :
ElMessage . warning ( ` 未知操作类型: ${ type } ` )
return
}
ElMessage . success ( '操作成功' )
messages . value [ msgIdx ] . actions = [ ]
} catch ( e ) {
ElMessage . error ( e . message || e . detail || '操作失败' )
} finally {
loading . value = false
}
}
const sendQuick = ( text ) => {
inputText . value = text
send ( )
}
const fetchSuggestions = async ( ) => {
try {
const res = await aiQuickQuestions ( )
if ( Array . isArray ( res ) ) suggestions . value = res
} catch { }
}
const onOpened = ( ) => {
if ( suggestions . value . length === 0 ) fetchSuggestions ( )
showSuggestions . value = messages . value . length <= 1
scrollToBottom ( )
}
const send = async ( ) => {
const msg = inputText . value . trim ( )
if ( ! msg || loading . value ) return
inputText . value = ''
showSuggestions . value = false
messages . value . push ( { role : 'user' , content : msg } )
loading . value = true
scrollToBottom ( )
try {
const hist = messages . value . map ( m => ( { role : m . role , content : m . content } ) )
const res = await aiChat ( msg , hist . slice ( 0 , - 1 ) )
const newMsg = { role : 'assistant' , content : res . reply || '抱歉,我没有理解,请重新描述一下你的问题。' }
if ( res . actions && res . actions . length ) {
newMsg . actions = res . actions
}
messages . value . push ( newMsg )
} catch {
messages . value . push ( { role : 'assistant' , content : '请求失败,请稍后重试。' } )
} finally {
loading . value = false
scrollToBottom ( )
}
}
const scrollToBottom = async ( ) => {
await nextTick ( )
const el = msgContainer . value
if ( el ) el . scrollTop = el . scrollHeight
}
< / script >
< style scoped >
. ai - float - btn {
position : fixed ;
right : 30 px ;
bottom : 30 px ;
width : 52 px ;
height : 52 px ;
border - radius : 50 % ;
background : linear - gradient ( 135 deg , # 667 eea , # 764 ba2 ) ;
display : flex ;
align - items : center ;
justify - content : center ;
box - shadow : 0 4 px 20 px rgba ( 102 , 126 , 234 , 0.4 ) ;
z - index : 9999 ;
cursor : pointer ;
transition : transform 0.2 s ;
}
. ai - float - btn : hover {
transform : scale ( 1.08 ) ;
}
. ai - float - icon {
color : # fff ;
font - size : 18 px ;
font - weight : bold ;
}
. ai - dialog {
-- el - dialog - content - padding : 0 ;
}
. ai - dialog : deep ( . el - dialog _ _body ) {
padding : 0 ;
height : 480 px ;
overflow : hidden ;
display : flex ;
flex - direction : column ;
}
. ai - messages {
flex : 1 ;
padding : 16 px ;
overflow - y : auto ;
background : # f5f5f5 ;
}
. ai - msg - row {
margin - bottom : 14 px ;
display : flex ;
gap : 8 px ;
}
. ai - msg - row . user {
flex - direction : row - reverse ;
}
. ai - avatar {
width : 32 px ;
height : 32 px ;
border - radius : 50 % ;
background : # f0edff ;
display : flex ;
align - items : center ;
justify - content : center ;
flex - shrink : 0 ;
}
. ai - msg - body {
max - width : 80 % ;
}
. ai - msg - bubble {
padding : 10 px 14 px ;
border - radius : 8 px ;
background : # fff ;
box - shadow : 0 1 px 3 px rgba ( 0 , 0 , 0 , 0.06 ) ;
}
. ai - msg - row . user . ai - msg - bubble {
background : # 667 eea ;
color : # fff ;
}
. ai - msg - text {
font - size : 13 px ;
line - height : 1.6 ;
white - space : pre - wrap ;
word - break : break - word ;
}
. ai - msg - row . user . ai - msg - text {
color : # fff ;
}
. ai - action - card {
margin - top : 12 px ;
border - top : 1 px solid # eee ;
padding - top : 10 px ;
}
. ai - action - title {
font - size : 14 px ;
font - weight : 600 ;
color : # 333 ;
margin - bottom : 10 px ;
}
. ai - field - row {
margin - bottom : 8 px ;
}
. ai - field - label {
display : block ;
font - size : 12 px ;
color : # 666 ;
margin - bottom : 3 px ;
}
. ai - action - btns {
display : flex ;
gap : 8 px ;
margin - top : 10 px ;
}
. ai - loading {
text - align : center ;
padding : 12 px 0 ;
display : flex ;
align - items : center ;
justify - content : center ;
gap : 6 px ;
color : # 999 ;
font - size : 13 px ;
}
. ai - suggestions {
padding : 8 px 0 ;
}
. ai - suggestion {
background : # f0edff ;
border - radius : 6 px ;
padding : 8 px 12 px ;
margin - bottom : 8 px ;
font - size : 13 px ;
color : # 667 eea ;
cursor : pointer ;
transition : background 0.15 s ;
}
. ai - suggestion : hover {
background : # e0dbff ;
}
. ai - input - bar {
display : flex ;
gap : 8 px ;
padding : 10 px 16 px ;
border - top : 1 px solid # f0f0f0 ;
}
< / style >