Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
Y
yd-middle-front
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
1
Merge Requests
1
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
xingmin
yd-middle-front
Commits
e52639b8
Commit
e52639b8
authored
Apr 01, 2026
by
zhangxingmin
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
p
parent
76387c42
Show whitespace changes
Inline
Side-by-side
Showing
2 changed files
with
275 additions
and
25 deletions
+275
-25
src/api/ai/ai.js
+46
-6
src/views/ai/index.vue
+229
-19
No files found.
src/api/ai/ai.js
View file @
e52639b8
...
...
@@ -14,9 +14,6 @@ export function randList(data) {
/**
* 查询输出流信息(流式响应)
* @param {string} question 用户问题
* @param {number} timeout 超时时间(毫秒),默认 300000
* @returns {Promise<Response>} fetch 响应对象
*/
export
function
getStream
(
question
,
timeout
=
300000
)
{
const
userStore
=
useUserStore
()
...
...
@@ -31,12 +28,9 @@ export function getStream(question, timeout = 300000) {
headers
[
'X-Tenant-ID'
]
=
tenantId
}
// 从环境变量获取基础 URL
const
baseUrl
=
import
.
meta
.
env
.
VITE_APP_BASE_API
// 拼接完整 URL(注意:后端实际路径为 /ai/api/ai/stream)
const
url
=
`
${
baseUrl
}
/ai/api/api/ai/stream?question=
${
encodeURIComponent
(
question
)}
`
// 使用 AbortController 实现超时控制
const
controller
=
new
AbortController
()
const
timeoutId
=
setTimeout
(()
=>
controller
.
abort
(),
timeout
)
...
...
@@ -48,3 +42,48 @@ export function getStream(question, timeout = 300000) {
clearTimeout
(
timeoutId
)
})
}
/**
* 获取服务卡片列表(产品列表)
*/
export
async
function
getServiceCardList
()
{
const
userStore
=
useUserStore
()
const
token
=
getToken
()
// 根据实际 store 获取 userId,如果不存在则使用默认值(示例中为 '30')
const
userId
=
userStore
.
userInfo
?.
userId
||
userStore
.
userId
||
'30'
// 处理 token 格式(去掉 "Bearer " 前缀)
let
cleanToken
=
token
if
(
token
&&
token
.
startsWith
(
'Bearer '
))
{
cleanToken
=
token
.
slice
(
7
)
}
const
headers
=
{
'Content-Type'
:
'application/json'
,
'x-authorization'
:
`sfpfamilyfinancialplanning
${
cleanToken
}
`
}
const
url
=
'https://hoservice.ydhomeoffice.cn/hoserviceApi/ydServiceCard/serviceCardList'
const
body
=
{
userId
:
String
(
userId
),
cardType
:
'11'
}
try
{
const
response
=
await
fetch
(
url
,
{
method
:
'POST'
,
headers
:
headers
,
body
:
JSON
.
stringify
(
body
)
})
if
(
!
response
.
ok
)
{
throw
new
Error
(
`HTTP
${
response
.
status
}
:
${
response
.
statusText
}
`
)
}
const
data
=
await
response
.
json
()
return
data
}
catch
(
error
)
{
console
.
error
(
'获取服务卡片列表失败:'
,
error
)
throw
error
}
}
\ No newline at end of file
src/views/ai/index.vue
View file @
e52639b8
...
...
@@ -19,7 +19,7 @@
</div>
</div>
<div
class=
"chat-messages"
ref=
"messagesContainer"
>
<div
class=
"chat-messages"
ref=
"messagesContainer"
@
click=
"handleProductClick"
>
<div
v-for=
"(msg, idx) in messages"
:key=
"idx"
:class=
"['message', msg.role]"
>
<div
class=
"message-avatar"
>
<div
v-if=
"msg.role === 'user'"
class=
"user-avatar"
>
U
</div>
...
...
@@ -30,7 +30,9 @@
</svg>
</div>
</div>
<div
class=
"message-content"
v-html=
"formatMessage(msg.content)"
></div>
<!-- 产品列表消息直接渲染HTML,其他消息使用markdown -->
<div
class=
"message-content"
v-if=
"msg.isProductList"
v-html=
"msg.content"
></div>
<div
class=
"message-content"
v-else
v-html=
"formatMessage(msg.content)"
></div>
</div>
<div
v-if=
"isLoading"
class=
"message assistant"
>
<div
class=
"message-avatar"
>
...
...
@@ -106,11 +108,14 @@
</template>
<
script
>
import
{
randList
,
getStream
}
from
'@/api/ai/ai'
import
{
randList
,
getStream
,
getServiceCardList
}
from
'@/api/ai/ai'
import
{
marked
}
from
'marked'
import
hljs
from
'highlight.js'
import
'highlight.js/styles/github-dark.css'
// 购买关键词正则(可根据需要调整)
const
PURCHASE_KEYWORDS
=
/购买|产品|商品|套餐|服务|有哪些产品|商品列表|购买服务|推荐产品|产品列表|选购|下单/i
// 配置 marked,增强安全性
marked
.
setOptions
({
highlight
:
function
(
code
,
lang
)
{
...
...
@@ -121,8 +126,8 @@ marked.setOptions({
},
breaks
:
true
,
gfm
:
true
,
mangle
:
false
,
// 禁用 HTML 实体编码,防止 XSS
headerIds
:
false
,
// 不自动生成标题 id
mangle
:
false
,
headerIds
:
false
,
})
export
default
{
...
...
@@ -144,7 +149,6 @@ export default {
this
.
loadSuggestions
()
},
methods
:
{
// 滚动到底部
scrollToBottom
()
{
this
.
$nextTick
(()
=>
{
const
container
=
this
.
$refs
.
messagesContainer
...
...
@@ -154,7 +158,6 @@ export default {
})
},
// 加载随机词条
async
loadSuggestions
(
randNum
=
4
)
{
this
.
suggestionsLoading
=
true
try
{
...
...
@@ -176,14 +179,12 @@ export default {
}
},
// 换一批
refreshSuggestions
()
{
if
(
!
this
.
suggestionsLoading
)
{
this
.
loadSuggestions
(
4
)
}
},
// 点击词条
handleSuggestionClick
(
content
)
{
if
(
this
.
isLoading
)
return
if
(
content
&&
content
.
trim
())
{
...
...
@@ -192,25 +193,130 @@ export default {
}
},
// 格式化消息(Markdown 转 HTML)
formatMessage
(
content
)
{
if
(
!
content
)
return
''
// 对用户消息内容进行额外转义(防止恶意 HTML)
// 但 marked 已做安全处理,此步可省略,保留原样
return
marked
.
parse
(
content
)
},
// 生成产品列表HTML
buildProductListHtml
(
productData
)
{
if
(
!
productData
||
!
productData
.
list
||
!
productData
.
list
.
length
)
{
return
'<div class="product-list-empty">暂无产品信息</div>'
}
let
html
=
'<div class="product-list">'
productData
.
list
.
forEach
(
card
=>
{
// 获取卡片主图(取第一个子项的图片)
const
mainImage
=
card
.
list
&&
card
.
list
[
0
]?.
itemImg
||
''
// 获取卡片标识(用于购买跳转)
const
cardCode
=
card
.
list
&&
card
.
list
[
0
]?.
cardCode
||
''
const
cardName
=
card
.
cardName
||
''
const
price
=
card
.
price
||
'0'
html
+=
`
<div class="product-card" data-product-code="
${
cardCode
}
" data-product-name="
${
cardName
}
">
${
mainImage
?
`<div class="product-image"><img src="
${
mainImage
}
" alt="
${
cardName
}
" loading="lazy"></div>`
:
''
}
<div class="product-info">
<div class="product-title">
${
escapeHtml
(
cardName
)}
</div>
<div class="product-price">¥
${
parseFloat
(
price
).
toFixed
(
2
)}
</div>
<button class="product-buy-btn" data-product-code="
${
cardCode
}
" data-product-name="
${
cardName
}
">立即购买</button>
</div>
</div>
`
})
html
+=
'</div>'
return
html
},
// 检查是否包含购买关键词
isPurchaseIntent
(
text
)
{
return
PURCHASE_KEYWORDS
.
test
(
text
)
},
// 获取并展示产品列表
async
fetchAndShowProducts
(
userQuestion
)
{
this
.
isLoading
=
true
try
{
const
response
=
await
getServiceCardList
()
if
(
response
.
success
&&
response
.
data
&&
response
.
data
.
list
)
{
const
productHtml
=
this
.
buildProductListHtml
(
response
.
data
)
const
messageContent
=
`
<div class="product-intro">根据您的问题“
${
escapeHtml
(
userQuestion
)}
”,为您推荐以下产品:</div>
${
productHtml
}
`
this
.
messages
.
push
({
role
:
'assistant'
,
content
:
messageContent
,
isProductList
:
true
})
}
else
{
this
.
messages
.
push
({
role
:
'assistant'
,
content
:
'抱歉,获取产品列表失败,请稍后重试。'
,
isProductList
:
false
})
}
this
.
scrollToBottom
()
}
catch
(
error
)
{
console
.
error
(
'获取产品列表失败:'
,
error
)
this
.
messages
.
push
({
role
:
'assistant'
,
content
:
'获取产品列表失败,请检查网络后重试。'
,
isProductList
:
false
})
this
.
scrollToBottom
()
}
finally
{
this
.
isLoading
=
false
}
},
// 处理产品卡片点击(事件委托)
handleProductClick
(
event
)
{
const
target
=
event
.
target
// 查找实际触发的购买按钮或卡片本身
let
productElement
=
target
.
closest
(
'.product-buy-btn'
)
||
target
.
closest
(
'.product-card'
)
if
(
!
productElement
)
return
// 获取产品标识
let
productCode
=
productElement
.
dataset
.
productCode
let
productName
=
productElement
.
dataset
.
productName
// 如果是按钮,从按钮上获取;如果是卡片,从卡片上获取
if
(
!
productCode
)
{
const
card
=
productElement
.
closest
(
'.product-card'
)
if
(
card
)
{
productCode
=
card
.
dataset
.
productCode
productName
=
card
.
dataset
.
productName
}
}
if
(
productCode
)
{
// 跳转到购买页面(根据实际商城地址调整)
const
buyUrl
=
`https://hoservice.ydhomeoffice.cn/ydMall/productDetail?cardCode=
${
productCode
}
`
window
.
open
(
buyUrl
,
'_blank'
)
}
else
{
console
.
warn
(
'缺少产品标识,无法跳转'
)
}
},
async
sendMessage
()
{
if
(
!
this
.
question
.
trim
()
||
this
.
isLoading
)
return
const
userQuestion
=
this
.
question
.
trim
()
this
.
messages
.
push
({
role
:
'user'
,
content
:
userQuestion
})
this
.
question
=
''
this
.
isLoading
=
true
this
.
scrollToBottom
()
// 立即滚动显示用户消息
this
.
scrollToBottom
()
// 检查是否包含购买关键词
if
(
this
.
isPurchaseIntent
(
userQuestion
))
{
await
this
.
fetchAndShowProducts
(
userQuestion
)
return
}
// 正常流式回答
this
.
isLoading
=
true
const
assistantMessageIndex
=
this
.
messages
.
length
this
.
messages
.
push
({
role
:
'assistant'
,
content
:
''
})
this
.
messages
.
push
({
role
:
'assistant'
,
content
:
''
,
isProductList
:
false
})
try
{
const
response
=
await
getStream
(
userQuestion
)
...
...
@@ -242,7 +348,6 @@ export default {
}
else
if
(
line
.
startsWith
(
'message:'
))
{
content
=
line
.
slice
(
8
).
trim
()
}
else
if
(
line
.
startsWith
(
'event:'
))
{
// 忽略事件类型行
continue
}
else
{
content
=
line
...
...
@@ -251,12 +356,11 @@ export default {
if
(
content
)
{
accumulatedContent
+=
content
this
.
messages
[
assistantMessageIndex
].
content
=
accumulatedContent
this
.
scrollToBottom
()
// 每次更新内容后滚动
this
.
scrollToBottom
()
}
}
}
// 处理剩余 buffer
if
(
buffer
.
trim
())
{
let
content
=
buffer
if
(
buffer
.
startsWith
(
'data:'
))
content
=
buffer
.
slice
(
5
).
trim
()
...
...
@@ -269,7 +373,6 @@ export default {
}
}
catch
(
error
)
{
console
.
error
(
'流式请求失败:'
,
error
)
// 处理超时或中断错误
const
errorMsg
=
error
.
name
===
'AbortError'
?
'请求超时,请稍后重试'
:
`错误:
${
error
.
message
}
`
this
.
messages
[
assistantMessageIndex
].
content
=
`❌
${
errorMsg
}
`
this
.
scrollToBottom
()
...
...
@@ -288,6 +391,17 @@ export default {
}
}
}
// HTML转义函数,防止XSS
function
escapeHtml
(
str
)
{
if
(
!
str
)
return
''
return
str
.
replace
(
/&/g
,
'&'
)
.
replace
(
/</g
,
'<'
)
.
replace
(
/>/g
,
'>'
)
.
replace
(
/"/g
,
'"'
)
.
replace
(
/'/g
,
'''
)
}
</
script
>
<
style
scoped
>
...
...
@@ -426,6 +540,102 @@ export default {
box-shadow
:
0
2px
8px
rgba
(
0
,
0
,
0
,
0.05
);
}
/* 产品列表样式 */
.message-content
:deep
(
.product-list
)
{
display
:
grid
;
grid-template-columns
:
repeat
(
auto-fill
,
minmax
(
240px
,
1
fr
));
gap
:
16px
;
margin-top
:
12px
;
}
.message-content
:deep
(
.product-card
)
{
background
:
#fff
;
border-radius
:
16px
;
overflow
:
hidden
;
box-shadow
:
0
2px
12px
rgba
(
0
,
0
,
0
,
0.08
);
transition
:
transform
0.2s
,
box-shadow
0.2s
;
cursor
:
pointer
;
border
:
1px
solid
#f0f0f0
;
}
.message-content
:deep
(
.product-card
:hover
)
{
transform
:
translateY
(
-4px
);
box-shadow
:
0
8px
24px
rgba
(
108
,
92
,
231
,
0.15
);
}
.message-content
:deep
(
.product-image
)
{
width
:
100%
;
height
:
160px
;
overflow
:
hidden
;
background
:
#f5f5f5
;
}
.message-content
:deep
(
.product-image
img
)
{
width
:
100%
;
height
:
100%
;
object-fit
:
cover
;
transition
:
transform
0.3s
;
}
.message-content
:deep
(
.product-card
:hover
.product-image
img
)
{
transform
:
scale
(
1.05
);
}
.message-content
:deep
(
.product-info
)
{
padding
:
12px
;
}
.message-content
:deep
(
.product-title
)
{
font-size
:
15px
;
font-weight
:
600
;
color
:
#1a1a2e
;
margin-bottom
:
8px
;
line-height
:
1.4
;
display
:
-webkit-box
;
-webkit-line-clamp
:
2
;
-webkit-box-orient
:
vertical
;
overflow
:
hidden
;
}
.message-content
:deep
(
.product-price
)
{
font-size
:
18px
;
font-weight
:
700
;
color
:
#6c5ce7
;
margin-bottom
:
12px
;
}
.message-content
:deep
(
.product-buy-btn
)
{
width
:
100%
;
padding
:
8px
0
;
background
:
linear-gradient
(
135deg
,
#6c5ce7
,
#a855f7
);
border
:
none
;
border-radius
:
24px
;
color
:
white
;
font-size
:
14px
;
font-weight
:
500
;
cursor
:
pointer
;
transition
:
opacity
0.2s
;
}
.message-content
:deep
(
.product-buy-btn
:hover
)
{
opacity
:
0.9
;
}
.message-content
:deep
(
.product-intro
)
{
font-size
:
14px
;
color
:
#6c757d
;
margin-bottom
:
12px
;
padding
:
8px
0
;
border-bottom
:
1px
solid
#e9ecef
;
}
.message-content
:deep
(
.product-list-empty
)
{
text-align
:
center
;
padding
:
32px
;
color
:
#94a3b8
;
font-size
:
14px
;
}
.chat-input-area
{
background
:
white
;
border-top
:
1px
solid
rgba
(
0
,
0
,
0
,
0.05
);
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment