Skip to content
GitLab
Projects
Groups
Snippets
/
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
水玉婷
ai-wechat
Commits
796092f1
Commit
796092f1
authored
Apr 09, 2026
by
水玉婷
Browse files
feat:文件上传及踩添加反馈弹窗
parent
72779ad7
Changes
14
Hide whitespace changes
Inline
Side-by-side
src/views/History.vue
View file @
796092f1
...
...
@@ -175,21 +175,21 @@ const getApiBaseUrl = () => {
const
apiBaseUrl
=
getApiBaseUrl
();
const
userInfo
=
localStorage
.
getItem
(
'
wechat_user
'
);
const
{
extMap
=
{}
}
=
JSON
.
parse
(
userInfo
||
'
{}
'
);
const
{
extMap
=
{}
,
appId
=
''
}
=
JSON
.
parse
(
userInfo
||
'
{}
'
);
const
userToken
=
extMap
.
sessionId
;
const
appCode
=
import
.
meta
.
env
.
VITE_APP_CODE
||
'
ped.qywx
'
;
// 基础配置对象
const
baseConfig
=
{
apiBaseUrl
,
token
:
userToken
,
userToken
,
appCode
};
const
chatParams
=
{
appId
:
'
83b2664019a945d0a438abe6339758d8
'
,
stage
:
'
wechat-demo
'
,
};
const
time
=
new
Date
().
getTime
();
const
chatParams
=
{
appId
:
appId
,
// 企业微信应用ID
stage
:
'
wechat-demo
'
+
time
,
};
const
totalCount
=
ref
(
0
);
const
appName
=
ref
(
''
);
interface
Session
{
...
...
src/views/Home.vue
View file @
796092f1
...
...
@@ -36,12 +36,12 @@
// 基础配置对象
const
baseConfig
=
{
apiBaseUrl
,
token
:
userToken
,
userToken
,
appCode
};
const
time
=
new
Date
().
getTime
();
const
chatParams
=
{
appId
:
appId
||
'
83b2664019a945d0a438abe6339758d8
'
,
// 企业微信应用ID
appId
:
appId
,
// 企业微信应用ID
stage
:
'
wechat-demo
'
+
time
,
};
const
dialogSessionId
=
ref
(
''
);
...
...
src/views/Login.vue
View file @
796092f1
...
...
@@ -48,11 +48,11 @@ export default {
onMounted
(()
=>
{
// 检查是否已登录
const
status
=
wechat
.
checkLoginStatus
()
if
(
status
.
isLoggedIn
)
{
router
.
replace
(
'
/
'
)
return
}
//
const status = wechat.checkLoginStatus()
//
if (status.isLoggedIn) {
//
router.replace('/')
//
return
//
}
// 执行静默登录
handleLogin
()
...
...
src/views/components/AiChat.vue
View file @
796092f1
...
...
@@ -6,7 +6,7 @@
<img
:src=
"props?.logoUrl || defaultAvatar"
alt=
"avatar"
class=
"avatar-image"
/>
</div>
<div
class=
"header-info"
>
<h2>
{{
props
?.
dialogSessionId
?
appData
?.
app_name
e
||
'
继续对话
'
:
'
新建对话
'
}}
</h2>
<h2>
{{
props
?.
dialogSessionId
?
appData
?.
app_name
||
'
继续对话
'
:
'
新建对话
'
}}
</h2>
</div>
</div>
...
...
@@ -37,13 +37,30 @@
<div
class=
"message-content-wrapper"
>
<div
class=
"message-content"
>
<
template
v-for=
"(item, i) in msg.contentBlocks"
:key=
"i"
>
<!-- 附件内容块 -->
<div
v-if=
"item.attachmentData"
class=
"attachment-block"
>
<div
class=
"attachment-display"
@
click=
"previewAttachment(item.attachmentData as any)"
>
<img
v-if=
"item.attachmentData.attachmentType?.startsWith?.('image/')"
:src=
"item.attachmentData.attachmentPath || item.content"
:alt=
"item.attachmentData.attachmentName"
class=
"message-attachment-image"
/>
<div
v-else
class=
"file-display"
>
<span
class=
"file-icon"
>
{{
getFileTypeIcon
(
item
.
attachmentData
.
attachmentType
||
''
)
}}
</span>
<div
class=
"file-info"
>
<span
class=
"file-name"
>
{{
item
.
attachmentData
.
attachmentName
}}
</span>
<span
class=
"file-size"
>
{{
formatFileSize
(
item
.
attachmentData
.
attachmentSize
||
0
)
}}
</span>
</div>
</div>
</div>
<!-- 普通内容块 -->
<div
class=
"message-inner-box"
@
click=
"msg.messageType === 'sent' ? handleMessageClick(msg, item, textarea) : null"
>
<div
v-html=
"item.content"
></div>
</div>
</div>
<!-- 图表内容块 -->
<div
v-if=
"item.chartData"
class=
"chart-block"
>
<div
v-
else-
if=
"item.chartData"
class=
"chart-block"
>
<ChartComponent
:chart-data=
"item.chartData"
:chart-type=
"item.chartType || 'column'"
:title=
"item.chartData.title || '图表数据'"
/>
</div>
<!-- 普通内容块 -->
<div
v-else
class=
"message-inner-box"
@
click=
"msg.messageType === 'sent' ? handleMessageClick(msg, item) : null"
>
<div
v-else
class=
"message-inner-box"
@
click=
"msg.messageType === 'sent' ? handleMessageClick(msg, item
, textarea
) : null"
>
<div
v-html=
"item.content"
></div>
</div>
<!-- 思考过程框 -->
...
...
@@ -97,10 +114,22 @@
<button
class=
"operation-btn copy-btn"
@
click=
"handleCopy(msg)"
title=
"复制"
>
<copy-outlined
/>
</button>
<button
class=
"operation-btn like-btn"
@
click=
"handleLike(msg)"
title=
"赞"
>
<button
class=
"operation-btn like-btn"
@
click=
"likeMessage(msg)"
:class=
"{ disabled: msg.upCount > 0 }"
:disabled=
"msg.upCount > 0"
:title=
"msg.upCount > 0 ? '已点赞' : '赞'"
>
<like-outlined
/>
</button>
<button
class=
"operation-btn dislike-btn"
@
click=
"handleDislike(msg)"
title=
"踩"
>
<button
class=
"operation-btn dislike-btn"
@
click=
"dislikeMessage(msg)"
:class=
"{ disabled: msg.downCount > 0 }"
:disabled=
"msg.downCount > 0"
:title=
"msg.downCount > 0 ? '已踩' : '踩'"
>
<dislike-outlined
/>
</button>
</div>
...
...
@@ -110,17 +139,63 @@
</div>
</div>
<!-- 附件预览区域(只显示一个附件) -->
<div
v-if=
"hasAttachment"
class=
"attachments-preview-container"
>
<div
class=
"attachment-item"
>
<div
class=
"attachment-preview"
>
<img
v-if=
"currentAttachment.attachmentType.startsWith('image/')"
:src=
"currentAttachment.previewUrl"
:alt=
"currentAttachment.attachmentName"
class=
"preview-image"
/>
<div
v-else
class=
"file-icon"
>
<span
class=
"file-type"
>
{{ getFileTypeIcon(currentAttachment.attachmentType) }}
</span>
</div>
<div
class=
"attachment-info"
>
<span
class=
"attachment-name"
>
{{ currentAttachment.attachmentName }}
</span>
<span
class=
"attachment-size"
>
{{ formatFileSize(currentAttachment.attachmentSize) }}
</span>
</div>
</div>
<div
class=
"attachment-actions"
>
<button
@
click=
"previewAttachment(currentAttachment)"
class=
"action-btn preview-btn"
title=
"预览"
>
<eye-outlined
/>
</button>
<button
@
click=
"removeAttachment(currentAttachment.id)"
class=
"action-btn remove-btn"
title=
"删除"
>
<delete-outlined
/>
</button>
</div>
</div>
</div>
<!-- 反馈弹窗 -->
<FeedbackModal
:visible=
"feedbackModalData.visible"
@
update:visible=
"(value) => feedbackModalData.visible = value"
@
submit=
"submitFeedback"
/>
<!-- 输入区域 - 始终显示 -->
<div
class=
"chat-input-container"
>
<div
class=
"chat-input"
>
<textarea
ref=
"textarea"
v-model=
"messageText"
placeholder=
"输入消息..."
@
keypress=
"handleKeyPress"
@
input=
"adjustTextareaHeight"
@
paste=
"(event) => handlePaste(event, uploadConfig)"
></textarea>
<!-- 上传附件按钮(放在右边) -->
<div
class=
"upload-wrapper"
>
<input
ref=
"fileInput"
type=
"file"
:accept=
"getAcceptTypes()"
@
change=
"(event) => handleFileSelect(event, uploadConfig)"
style=
"display: none"
/>
<button
@
click=
"triggerFileInput(fileInput)"
class=
"upload-button"
title=
"上传附件"
:disabled=
"loading"
>
<paper-clip-outlined
/>
</button>
</div>
<!-- 语音识别按钮 -->
<VoiceRecognitionText
ref=
"voiceRecognitionRef"
:disabled=
"loading"
:debug=
"true"
:baseConfig=
"baseConfig"
@
text=
"handleVoiceText"
@
error=
"handleVoiceError"
class=
"voice-recognition-wrapper"
/>
<textarea
ref=
"textarea"
v-model=
"messageText"
placeholder=
"输入消息..."
@
keypress=
"handleKeyPress"
@
input=
"adjustTextareaHeight"
></textarea>
<button
@
click=
"sendMessage()"
:disabled=
"loading"
class=
"send-button"
>
<send-outlined
/>
</button>
...
...
@@ -133,13 +208,16 @@
import
{
ref
,
onMounted
,
onBeforeUnmount
,
nextTick
}
from
'
vue
'
;
import
dayjs
from
'
dayjs
'
;
import
{
markdownTemplate
,
isLastBlockMarkdown
,
getLastMarkdownBlockIndex
,
mergeMarkdownContent
}
from
'
./utils/markdownTemplate
'
;
import
{
SendOutlined
,
ReloadOutlined
,
CopyOutlined
,
LikeOutlined
,
DislikeOutlined
}
from
'
@ant-design/icons-vue
'
;
import
{
message
as
antdMessage
}
from
'
ant-design-vue
'
;
import
defaultAvatar
from
'
@/assets/logo.png
'
;
import
rightIcon
from
'
@/assets/right.svg
'
import
thinkIcon
from
'
@/assets/think.svg
'
import
{
SendOutlined
,
ReloadOutlined
,
CopyOutlined
,
LikeOutlined
,
DislikeOutlined
,
PaperClipOutlined
,
EyeOutlined
,
DeleteOutlined
}
from
'
@ant-design/icons-vue
'
;
import
{
useAttachments
}
from
'
./hooks/useAttachments
'
;
import
{
useFeedback
}
from
'
./hooks/useFeedback
'
;
import
{
useMessageActions
}
from
'
./hooks/useMessageActions
'
;
import
defaultAvatar
from
'
../../assets/logo.png
'
;
import
rightIcon
from
'
../../assets/right.svg
'
import
thinkIcon
from
'
../../assets/think.svg
'
import
ChartComponent
from
'
./ChartComponent.vue
'
;
// 导入独立的图表组件
import
VoiceRecognitionText
from
'
./VoiceRecognitionText.vue
'
;
// 导入语音识别组件
import
FeedbackModal
from
'
./FeedbackModal.vue
'
;
// 导入反馈弹窗组件
import
{
createContentTemplateService
,
type
Message
}
from
'
./utils/contentTemplateService
'
;
// 导入模板服务
// 定义组件属性接口
...
...
@@ -149,7 +227,7 @@ interface Props {
// 基础配置对象
baseConfig
?:
{
apiBaseUrl
?:
string
t
oken
?:
string
userT
oken
?:
string
appCode
?:
string
}
logoUrl
?:
string
...
...
@@ -168,7 +246,7 @@ const props = withDefaults(defineProps<Props>(), {
dialogSessionId
:
''
,
baseConfig
:
()
=>
({
apiBaseUrl
:
''
,
t
oken
:
''
,
userT
oken
:
''
,
appCode
:
''
}),
logoUrl
:
''
,
...
...
@@ -198,6 +276,39 @@ const currentAIResponse = ref<Message | null>(null);
const
isAIResponding
=
ref
(
false
);
const
dialogSessionId
=
ref
(
props
.
dialogSessionId
||
''
);
const
isInThinkingMode
=
ref
(
false
);
// 赞踩功能hooks
const
{
feedbackModalData
,
likeMessage
,
dislikeMessage
,
submitFeedback
}
=
useFeedback
(
props
.
baseConfig
);
// 消息操作hooks
const
{
handleMessageClick
,
handleCopy
}
=
useMessageActions
(
messageText
);
// 附件上传相关 - 使用hooks
const
{
attachments
,
hasAttachment
,
currentAttachment
,
getFileTypeIcon
,
formatFileSize
,
getAcceptTypes
,
previewAttachment
,
removeAttachment
,
clearAttachments
,
isUploading
,
handlePaste
,
handleFileSelect
,
triggerFileInput
}
=
useAttachments
();
const
fileInput
=
ref
<
HTMLInputElement
>
();
const
currentBlockIndex
=
ref
(
-
1
);
const
hasStartedConversation
=
ref
(
false
);
// 添加对话开始状态
...
...
@@ -221,6 +332,8 @@ const handleSSEMessage = (data: SSEData, isSimulated: boolean = false) => {
totalTokens
:
0
,
date
:
dayjs
().
format
(
'
HH:mm
'
),
contentBlocks
:
[],
upCount
:
0
,
downCount
:
0
,
};
messages
.
value
.
push
(
currentAIResponse
.
value
);
};
...
...
@@ -302,6 +415,13 @@ const handleSSEMessage = (data: SSEData, isSimulated: boolean = false) => {
// 创建模板服务实例
const
templateService
=
createContentTemplateService
();
// 上传配置
const
uploadConfig
=
{
apiBaseUrl
:
props
.
baseConfig
?.
apiBaseUrl
,
userToken
:
props
.
baseConfig
?.
userToken
,
appCode
:
props
.
baseConfig
?.
appCode
};
// 语音转文字事件处理函数
const
handleVoiceText
=
(
textMessage
:
string
)
=>
{
// 直接使用统一的sendMessage函数发送文本消息
...
...
@@ -322,7 +442,7 @@ const startConversation = () => {
};
// 定义消息类型
type
MessageType
=
'
text
'
|
'
audio
'
;
type
MessageType
=
'
text
'
|
'
audio
'
|
'
attachment
'
;
// 定义消息参数接口
interface
MessageParams
{
...
...
@@ -341,6 +461,8 @@ const validateMessageParams = (type: MessageType, params: MessageParams): boolea
return
!!
messageContent
;
case
'
audio
'
:
return
!!
audioUrl
;
case
'
attachment
'
:
return
attachments
.
value
.
length
>
0
;
default
:
return
false
;
}
...
...
@@ -348,6 +470,13 @@ const validateMessageParams = (type: MessageType, params: MessageParams): boolea
// 统一发送消息函数
const
sendMessage
=
async
(
type
:
MessageType
=
'
text
'
,
params
:
MessageParams
=
{})
=>
{
// 根据是否有附件自动确定消息类型
if
(
attachments
.
value
.
length
>
0
)
{
type
=
'
attachment
'
as
MessageType
;
}
if
(
isUploading
.
value
)
{
return
;
}
//如果消息文本为空且是文本类型,则延迟1秒后模拟折线图消息进行测试
// if (type === 'text' && !messageText.value.trim() && !params.message) {
// loading.value = true;
...
...
@@ -395,13 +524,20 @@ const sendMessage = async (type: MessageType = 'text', params: MessageParams = {
contentBlocks
:
[]
as
any
[],
status
:
1
,
// 发送中状态
originalContent
:
messageContent
,
// 保存原始内容用于重发
originalMessageType
:
type
// 保存消息类型用于重发
};
switch
(
type
)
{
originalMessageType
:
type
===
'
attachment
'
?
'
text
'
:
type
,
// 重发时使用text类型
attachments
:
attachments
.
value
.
map
(
a
=>
({
attachmentName
:
a
.
attachmentName
||
''
,
attachmentType
:
a
.
attachmentType
,
attachmentSize
:
a
.
attachmentSize
,
attachmentPath
:
a
.
attachmentPath
||
''
,
// 后台返回的URL
})),
upCount
:
0
,
downCount
:
0
,
}
as
Message
;
switch
(
type
)
{
case
'
text
'
:
messageData
.
contentBlocks
.
push
({
content
:
templateService
.
generate
Text
Template
(
messageContent
),
content
:
templateService
.
generate
Question
Template
(
messageContent
),
thinkContent
:
''
,
hasThinkBox
:
false
,
thinkBoxExpanded
:
false
,
...
...
@@ -413,6 +549,30 @@ const sendMessage = async (type: MessageType = 'text', params: MessageParams = {
messageText
.
value
=
''
;
break
;
case
'
attachment
'
:
// 处理附件消息
if
(
attachments
.
value
.
length
>
0
)
{
const
attachment
=
attachments
.
value
[
0
];
// 只取第一个附件(单文件限制)
messageData
.
contentBlocks
.
push
({
attachmentData
:
{
attachmentName
:
attachment
.
attachmentName
,
attachmentType
:
attachment
.
attachmentType
,
attachmentSize
:
attachment
.
attachmentSize
,
attachmentPath
:
attachment
.
attachmentPath
||
''
,
// 后台返回的URL
},
content
:
templateService
.
generateQuestionTemplate
(
messageContent
),
thinkContent
:
''
,
hasThinkBox
:
false
,
thinkBoxExpanded
:
false
,
});
}
// 重置文本输入框
if
(
textarea
.
value
)
{
textarea
.
value
.
style
.
height
=
'
52px
'
;
}
messageText
.
value
=
''
;
break
;
case
'
audio
'
:
messageData
.
contentBlocks
.
push
({
audioData
:
{
...
...
@@ -443,25 +603,48 @@ const sendMessage = async (type: MessageType = 'text', params: MessageParams = {
}
else
{
// 默认的API调用逻辑
console
.
log
(
`默认API调用逻辑`
,
dialogSessionId
.
value
);
const
requestData
=
type
===
'
audio
'
?
{
questionLocalAudioFilePath
:
audioUrl
,
audioDuration
:
durationTime
,
...
props
.
params
,
dialogSessionId
:
dialogSessionId
.
value
,
appId
:
props
.
params
?.
appId
,
}
:
{
question
:
messageContent
,
...
props
.
params
,
dialogSessionId
:
dialogSessionId
.
value
,
let
attachmentJson
:
any
=
null
;
let
requestData
:
any
=
{
...
props
.
params
,
dialogSessionId
:
dialogSessionId
.
value
,
appId
:
props
.
params
?.
appId
,
};
const
{
token
,
appCode
}
=
props
.
baseConfig
||
{};
switch
(
type
)
{
case
'
text
'
:
requestData
=
{
...
requestData
,
question
:
messageContent
,
};
break
;
case
'
attachment
'
:
attachmentJson
=
JSON
.
parse
(
JSON
.
stringify
(
attachments
.
value
[
0
]));
requestData
=
{
...
requestData
,
attachmentPath
:
attachmentJson
?.
attachmentPath
||
''
,
attachmentType
:
attachmentJson
?.
attachmentType
||
''
,
attachmentSize
:
attachmentJson
?.
attachmentSize
||
0
,
attachmentName
:
attachmentJson
?.
attachmentName
||
''
,
question
:
messageContent
,
}
clearAttachments
();
break
;
case
'
audio
'
:
requestData
=
{
...
requestData
,
questionLocalAudioFilePath
:
audioUrl
,
audioDuration
:
durationTime
,
}
break
;
default
:
break
;
}
const
{
userToken
,
appCode
}
=
props
.
baseConfig
||
{};
const
response
=
await
fetch
(
`
${
import
.
meta
.
env
.
VITE_SSE_PATH
}
/sse/ask`
,
{
method
:
'
POST
'
,
headers
:
{
'
Content-Type
'
:
'
application/json
'
,
'
token
'
:
t
oken
||
''
,
'
x-session-id
'
:
t
oken
||
''
,
'
token
'
:
userT
oken
||
''
,
'
x-session-id
'
:
userT
oken
||
''
,
'
x-app-code
'
:
appCode
||
''
}
as
HeadersInit
,
body
:
JSON
.
stringify
(
requestData
)
...
...
@@ -472,10 +655,14 @@ const sendMessage = async (type: MessageType = 'text', params: MessageParams = {
// 处理SSE流式响应
await
processSSEStreamResponse
(
response
.
body
);
console
.
log
(
`发送成功`
)
attachmentJson
=
null
;
// 发送成功,更新消息状态为已发送
if
(
messageData
)
{
messageData
.
status
=
2
;
}
// 发送成功后清空附件(在API调用完成后)
clearAttachments
();
}
else
{
loading
.
value
=
false
;
// 设置当前消息为失败状态
...
...
@@ -486,9 +673,12 @@ const sendMessage = async (type: MessageType = 'text', params: MessageParams = {
}
}
catch
(
e
)
{
loading
.
value
=
false
;
console
.
error
(
`发送失败:`
,
e
);
}
finally
{
loading
.
value
=
false
;
// 发送成功后清空附件
clearAttachments
();
}
};
...
...
@@ -519,57 +709,6 @@ const handleRecommendationClick = (message: Message, item: any) => {
sendMessage
(
'
text
'
,
{
message
:
item
.
title
});
};
// 处理消息点击 - 将消息内容放到输入框
const
handleMessageClick
=
(
message
:
Message
,
block
:
any
)
=>
{
// 只处理文字消息,不处理音频、图表等特殊消息
if
(
block
.
audioData
||
block
.
chartData
)
{
return
;
}
try
{
// 提取消息中的文本内容
let
textToInput
=
''
;
if
(
message
.
contentBlocks
&&
message
.
contentBlocks
.
length
>
0
)
{
message
.
contentBlocks
.
forEach
(
block
=>
{
// 跳过非文字内容块
if
(
block
.
audioData
||
block
.
chartData
)
{
return
;
}
if
(
block
.
content
)
{
// 移除HTML标签,只保留纯文本
const
textContent
=
block
.
content
.
replace
(
/<
[^
>
]
*>/g
,
''
).
trim
();
if
(
textContent
)
{
textToInput
+=
textContent
+
'
\n
'
;
}
}
});
}
// 如果没有内容,使用原始内容
if
(
!
textToInput
.
trim
()
&&
message
.
originalContent
)
{
textToInput
=
message
.
originalContent
;
}
if
(
textToInput
.
trim
())
{
// 将内容设置到输入框
messageText
.
value
=
textToInput
.
trim
();
// 自动调整输入框高度
adjustTextareaHeight
();
// 聚焦到输入框
if
(
textarea
.
value
)
{
textarea
.
value
.
focus
();
}
// 提示用户
antdMessage
.
success
(
'
消息内容已放入输入框
'
);
}
}
catch
(
error
)
{
console
.
error
(
'
处理消息点击失败:
'
,
error
);
}
};
// 处理SSE流式响应
const
processSSEStreamResponse
=
async
(
stream
:
ReadableStream
|
null
)
=>
{
if
(
!
stream
)
{
...
...
@@ -668,12 +807,12 @@ const getChatRecord = async (dialogSessionId: string) => {
messages
.
value
=
[...
recordList
];
}
}
else
{
const
{
t
oken
,
appCode
}
=
props
.
baseConfig
||
{};
const
{
userT
oken
,
appCode
}
=
props
.
baseConfig
||
{};
const
response
=
await
fetch
(
`
${
props
.
baseConfig
?.
apiBaseUrl
||
''
}
/aiService/ask/list/chat/
${
dialogSessionId
}
`
,
{
method
:
'
GET
'
,
headers
:
{
'
token
'
:
t
oken
||
''
,
'
x-session-id
'
:
t
oken
||
''
,
'
token
'
:
userT
oken
||
''
,
'
x-session-id
'
:
userT
oken
||
''
,
'
x-app-code
'
:
appCode
||
''
}
as
HeadersInit
});
...
...
@@ -694,12 +833,12 @@ const getAppInfo = async () => {
if
(
!
props
.
params
?.
appId
)
{
return
;
}
const
{
t
oken
,
appCode
}
=
props
.
baseConfig
||
{};
const
{
userT
oken
,
appCode
}
=
props
.
baseConfig
||
{};
const
response
=
await
fetch
(
`
${
import
.
meta
.
env
.
VITE_SSE_PATH
}
/apps/getAppInfoById/
${
props
.
params
?.
appId
}
`
,
{
method
:
'
GET
'
,
headers
:
{
'
token
'
:
t
oken
||
''
,
'
x-session-id
'
:
t
oken
||
''
,
'
token
'
:
userT
oken
||
''
,
'
x-session-id
'
:
userT
oken
||
''
,
'
x-app-code
'
:
appCode
||
''
}
as
HeadersInit
});
...
...
@@ -719,68 +858,8 @@ const toggleThinkBox = (messageIndex: number, blockIndex: number) => {
}
};
// 复制消息内容
const
handleCopy
=
async
(
msg
:
Message
)
=>
{
try
{
let
textToCopy
=
''
;
// 遍历所有内容块
msg
.
contentBlocks
.
forEach
(
block
=>
{
if
(
block
.
chartData
)
{
// 如果是表格内容,复制description
textToCopy
+=
block
.
chartData
.
description
||
''
;
}
else
if
(
block
.
content
)
{
// 检查是否是iframe内容
if
(
block
.
content
.
includes
(
'
message-iframe
'
))
{
// 提取iframe的src属性
const
srcMatch
=
block
.
content
.
match
(
/src="
([^
"
]
+
)
"/
);
if
(
srcMatch
&&
srcMatch
[
1
])
{
// 添加API基础URL到src前面
const
fullSrc
=
import
.
meta
.
env
.
VITE_API_BASE_URL
+
srcMatch
[
1
];
textToCopy
+=
fullSrc
+
'
\n
'
;
}
}
else
{
// 其他内容复制content
// 移除HTML标签,保留纯文本
const
textContent
=
block
.
content
.
replace
(
/<
[^
>
]
*>/g
,
''
).
trim
();
if
(
textContent
)
{
textToCopy
+=
textContent
+
'
\n
'
;
}
}
}
});
// 如果没有内容,使用原始内容
if
(
!
textToCopy
.
trim
()
&&
msg
.
originalContent
)
{
textToCopy
=
msg
.
originalContent
;
}
if
(
textToCopy
.
trim
())
{
// 使用Clipboard API复制到剪贴板
await
navigator
.
clipboard
.
writeText
(
textToCopy
.
trim
());
antdMessage
.
success
(
'
内容已复制到剪贴板
'
);
}
else
{
antdMessage
.
warning
(
'
没有可复制的内容
'
);
}
}
catch
(
error
)
{
console
.
error
(
'
复制失败:
'
,
error
);
antdMessage
.
error
(
'
复制失败,请手动复制
'
);
}
};
// 点赞消息
const
handleLike
=
(
msg
:
Message
)
=>
{
console
.
log
(
'
点赞消息:
'
,
msg
.
recordId
);
antdMessage
.
success
(
'
已点赞
'
);
// 这里可以添加实际的点赞API调用
};
// 踩消息
const
handleDislike
=
(
msg
:
Message
)
=>
{
console
.
log
(
'
踩消息:
'
,
msg
.
recordId
);
antdMessage
.
success
(
'
已踩
'
);
// 这里可以添加实际的踩API调用
};
// 处理按键事件
const
handleKeyPress
=
(
e
:
KeyboardEvent
)
=>
{
if
(
e
.
key
===
'
Enter
'
&&
!
e
.
shiftKey
)
{
...
...
src/views/components/FeedbackModal.vue
0 → 100644
View file @
796092f1
<
template
>
<a-modal
:visible=
"visible"
title=
"反馈"
:width=
"500"
centered
class=
"feedback-modal"
:maskClosable=
"false"
@
cancel=
"handleCancel"
@
update:visible=
"(value) => emit('update:visible', value)"
>
<div
class=
"feedback-content"
>
<div
class=
"feedback-input"
>
<a-textarea
v-model:value=
"feedbackContent"
placeholder=
"我们想知道您对此回答不满意的原因,您认为更好的回答是什么?"
:rows=
"8"
:maxlength=
"500"
show-count
/>
</div>
</div>
<template
#footer
>
<div
class=
"feedback-actions"
>
<a-button
@
click=
"handleCancel"
class=
"cancel-btn"
>
取消
</a-button>
<a-button
type=
"primary"
@
click=
"handleSubmit"
:disabled=
"!feedbackContent.trim()"
class=
"submit-btn"
>
提交
</a-button>
</div>
</
template
>
</a-modal>
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
watch
}
from
'
vue
'
;
import
{
message
}
from
'
ant-design-vue
'
;
// 定义组件属性
interface
Props
{
visible
:
boolean
;
}
interface
Emits
{
(
e
:
'
update:visible
'
,
value
:
boolean
):
void
;
(
e
:
'
submit
'
,
comment
:
string
):
void
;
}
const
props
=
defineProps
<
Props
>
();
const
emit
=
defineEmits
<
Emits
>
();
// 响应式数据
const
feedbackContent
=
ref
<
string
>
(
''
);
// 监听visible变化
watch
(()
=>
props
.
visible
,
(
newVal
)
=>
{
if
(
!
newVal
)
{
// 关闭时重置表单
resetForm
();
}
});
// 重置表单
const
resetForm
=
()
=>
{
feedbackContent
.
value
=
''
;
};
// 处理取消
const
handleCancel
=
()
=>
{
emit
(
'
update:visible
'
,
false
);
resetForm
();
};
// 处理提交
const
handleSubmit
=
async
()
=>
{
if
(
!
feedbackContent
.
value
.
trim
())
{
message
.
warning
(
'
请输入反馈内容
'
);
return
;
}
try
{
// 只emit comment数据,其他逻辑在hooks中处理
emit
(
'
submit
'
,
feedbackContent
.
value
.
trim
());
}
catch
(
error
)
{
console
.
error
(
'
提交反馈失败:
'
,
error
);
}
};
</
script
>
<
style
lang=
"less"
>
.feedback-modal {
max-width: calc(100vw - 50px) !important;
.ant-modal-body {
padding: 16px 16px 0;
}
.feedback-content {
.feedback-input {
margin-bottom: 30px;
}
.feedback-actions {
display: flex;
justify-content: flex-end;
gap: 16px;
.cancel-btn {
min-width: 60px;
}
.submit-btn {
min-width: 60px;
}
}
}
}
</
style
>
\ No newline at end of file
src/views/components/VoiceRecognition.vue
View file @
796092f1
...
...
@@ -59,7 +59,7 @@ interface Props {
debug
?:
boolean
maxDuration
?:
number
,
// 添加最大时长参数
baseConfig
?:
{
t
oken
?:
string
,
userT
oken
?:
string
,
appCode
?:
string
,
apiBaseUrl
?:
string
,
}
...
...
@@ -70,7 +70,7 @@ const props = withDefaults(defineProps<Props>(), {
debug
:
false
,
maxDuration
:
30
,
// 默认最大时长为30秒
baseConfig
:
()
=>
({
t
oken
:
''
,
userT
oken
:
''
,
appCode
:
''
,
apiBaseUrl
:
''
,
}
)
...
...
@@ -412,13 +412,13 @@ const uploadAudioFile = async (audioBlob: Blob): Promise<{filePath: string, dura
const
formData
=
new
FormData
();
formData
.
append
(
'
file
'
,
audioBlob
,
'
recording.wav
'
);
formData
.
append
(
'
fileFolder
'
,
'
AI_TEMP
'
);
const
{
t
oken
,
appCode
}
=
props
.
baseConfig
||
{
}
;
const
{
userT
oken
,
appCode
}
=
props
.
baseConfig
||
{
}
;
const
response
=
await
fetch
(
`${props.baseConfig?.apiBaseUrl || ''
}
/platformService/upload/v2`
,
{
method
:
'
POST
'
,
headers
:
{
'
x-app-code
'
:
appCode
||
''
,
'
token
'
:
t
oken
||
''
,
'
x-session-id
'
:
t
oken
||
''
,
'
token
'
:
userT
oken
||
''
,
'
x-session-id
'
:
userT
oken
||
''
,
}
as
HeadersInit
,
body
:
formData
}
);
...
...
src/views/components/VoiceRecognitionText.vue
View file @
796092f1
...
...
@@ -59,7 +59,7 @@ interface Props {
debug
?:
boolean
maxDuration
?:
number
,
// 添加最大时长参数
baseConfig
?:
{
t
oken
?:
string
,
userT
oken
?:
string
,
appCode
?:
string
,
apiBaseUrl
?:
string
,
}
...
...
@@ -70,7 +70,7 @@ const props = withDefaults(defineProps<Props>(), {
debug
:
false
,
maxDuration
:
30
,
// 默认最大时长为30秒
baseConfig
:
()
=>
({
t
oken
:
''
,
userT
oken
:
''
,
appCode
:
''
,
apiBaseUrl
:
''
,
}
)
...
...
@@ -413,13 +413,13 @@ const uploadAudioFile = async (audioBlob: Blob): Promise<string> => {
const
formData
=
new
FormData
();
formData
.
append
(
'
file
'
,
audioBlob
,
'
recording.wav
'
);
formData
.
append
(
'
fileFolder
'
,
'
AI_TEMP
'
);
const
{
t
oken
,
appCode
}
=
props
.
baseConfig
||
{
}
;
const
{
userT
oken
,
appCode
}
=
props
.
baseConfig
||
{
}
;
const
response
=
await
fetch
(
`/agentService/index/audio2txt`
,
{
method
:
'
POST
'
,
headers
:
{
'
x-app-code
'
:
appCode
||
''
,
'
token
'
:
t
oken
||
''
,
'
x-session-id
'
:
t
oken
||
''
,
'
token
'
:
userT
oken
||
''
,
'
x-session-id
'
:
userT
oken
||
''
,
}
as
HeadersInit
,
body
:
formData
}
);
...
...
src/views/components/hooks/useAttachments.ts
0 → 100644
View file @
796092f1
import
{
ref
,
computed
}
from
'
vue
'
;
import
{
message
as
antdMessage
}
from
'
ant-design-vue
'
;
export
interface
Attachment
{
id
:
string
;
file
:
File
;
previewUrl
:
string
;
// 本地预览URL
attachmentPath
:
string
;
// 后台返回的URL
attachmentName
:
string
;
attachmentType
:
string
;
attachmentSize
:
number
;
}
// 支持的文件扩展名
const
supportedFileExtensions
=
{
image
:
[
'
.png
'
,
'
.jpg
'
,
'
.jpeg
'
],
// document: ['.pdf', '.pptx', '.docx', '.doc', '.docx'],
// text: ['.txt', '.md']
};
// 上传配置接口
interface
UploadConfig
{
apiBaseUrl
?:
string
;
userToken
?:
string
;
appCode
?:
string
;
}
export
function
useAttachments
()
{
// 附件列表(只允许一个文件)
const
attachments
=
ref
<
Attachment
[]
>
([]);
// 是否有附件的计算属性
const
hasAttachment
=
computed
(()
=>
attachments
.
value
.
length
>
0
);
// 当前附件(只允许一个)
const
currentAttachment
=
computed
(()
=>
attachments
.
value
[
0
]
||
null
);
// 上传状态
const
isUploading
=
ref
(
false
);
// 上传文件到后台
const
uploadFileToServer
=
async
(
file
:
File
,
config
:
UploadConfig
):
Promise
<
string
>
=>
{
try
{
isUploading
.
value
=
true
;
const
formData
=
new
FormData
();
formData
.
append
(
'
file
'
,
file
);
formData
.
append
(
'
fileFolder
'
,
'
AI_TEMP
'
);
const
{
userToken
,
appCode
}
=
config
;
const
response
=
await
fetch
(
`
${
config
.
apiBaseUrl
||
''
}
/platformService/upload/v2`
,
{
method
:
'
POST
'
,
headers
:
{
'
x-app-code
'
:
appCode
||
''
,
'
token
'
:
userToken
||
''
,
'
x-session-id
'
:
userToken
||
''
,
}
as
HeadersInit
,
body
:
formData
});
const
result
=
await
response
.
json
();
if
(
result
.
code
===
0
)
{
return
result
.
data
.
filePath
;
}
else
{
throw
new
Error
(
result
.
message
||
'
上传失败
'
);
}
}
catch
(
error
)
{
console
.
error
(
'
文件上传失败:
'
,
error
);
throw
error
;
}
finally
{
isUploading
.
value
=
false
;
}
};
// 获取文件类型图标
const
getFileTypeIcon
=
(
fileType
:
string
):
string
=>
{
if
(
fileType
.
startsWith
(
'
image/
'
))
return
'
🖼️
'
;
if
(
fileType
.
includes
(
'
pdf
'
))
return
'
📄
'
;
if
(
fileType
.
includes
(
'
word
'
)
||
fileType
.
includes
(
'
document
'
))
return
'
📝
'
;
if
(
fileType
.
startsWith
(
'
text/
'
))
return
'
📄
'
;
return
'
📎
'
;
};
// 格式化文件大小
const
formatFileSize
=
(
bytes
:
number
):
string
=>
{
if
(
bytes
===
0
)
return
'
0 B
'
;
const
k
=
1024
;
const
sizes
=
[
'
B
'
,
'
KB
'
,
'
MB
'
,
'
GB
'
];
const
i
=
Math
.
floor
(
Math
.
log
(
bytes
)
/
Math
.
log
(
k
));
return
parseFloat
((
bytes
/
Math
.
pow
(
k
,
i
)).
toFixed
(
2
))
+
'
'
+
sizes
[
i
];
};
// 获取可接受的文件类型(用于文件选择器)
const
getAcceptTypes
=
():
string
=>
{
return
Object
.
values
(
supportedFileExtensions
).
flat
().
map
(
ext
=>
ext
).
join
(
'
,
'
);
};
// 检查文件是否支持
const
isFileSupported
=
(
file
:
File
):
boolean
=>
{
// 检查文件扩展名
const
fileExtension
=
'
.
'
+
file
.
name
.
split
(
'
.
'
).
pop
()?.
toLowerCase
();
const
isExtensionSupported
=
Object
.
values
(
supportedFileExtensions
).
flat
().
some
(
ext
=>
ext
===
fileExtension
);
if
(
!
isExtensionSupported
)
{
antdMessage
.
warning
(
`不支持的文件类型:
${
file
.
name
}
`
);
return
false
;
}
// 检查文件大小(限制为10MB)
if
(
file
.
size
>
10
*
1024
*
1024
)
{
antdMessage
.
warning
(
'
文件大小不能超过10MB
'
);
return
false
;
}
return
true
;
};
// 添加附件(替换现有附件)
const
addAttachment
=
async
(
file
:
File
,
config
?:
UploadConfig
):
Promise
<
boolean
>
=>
{
if
(
!
isFileSupported
(
file
))
return
false
;
// 清空现有附件
clearAttachments
();
// 创建附件对象(先创建本地预览)
const
attachment
:
Attachment
=
{
id
:
Math
.
random
().
toString
(
36
).
substr
(
2
,
9
),
file
:
file
,
previewUrl
:
file
.
type
.
startsWith
(
'
image/
'
)
?
URL
.
createObjectURL
(
file
)
:
''
,
attachmentPath
:
''
,
// 初始为空,稍后上传
attachmentName
:
file
.
name
,
attachmentType
:
file
.
type
,
attachmentSize
:
file
.
size
};
attachments
.
value
.
push
(
attachment
);
// 如果有上传配置,立即上传到后台
if
(
config
)
{
try
{
const
remoteUrl
=
await
uploadFileToServer
(
file
,
config
);
// 更新附件的远程URL
const
current
=
attachments
.
value
.
find
(
a
=>
a
.
id
===
attachment
.
id
);
if
(
current
)
{
current
.
attachmentPath
=
remoteUrl
;
}
}
catch
(
error
)
{
antdMessage
.
error
(
'
文件上传失败
'
);
// 上传失败时移除附件
removeAttachment
(
attachment
.
id
);
return
false
;
}
}
return
true
;
};
// 从粘贴事件添加附件
const
addAttachmentFromPaste
=
async
(
event
:
ClipboardEvent
,
config
?:
UploadConfig
):
Promise
<
boolean
>
=>
{
const
clipboardData
=
event
.
clipboardData
;
if
(
!
clipboardData
)
return
false
;
// 检查是否有支持的文件数据
const
items
=
Array
.
from
(
clipboardData
.
items
);
// 查找所有支持的文件类型
const
supportedItems
=
items
.
filter
(
item
=>
{
// 检查是否是文件类型
if
(
item
.
kind
===
'
file
'
)
{
const
file
=
item
.
getAsFile
();
if
(
file
)
{
return
isFileSupported
(
file
);
// 使用现有的文件支持检查函数
}
}
return
false
;
});
if
(
supportedItems
.
length
>
0
)
{
event
.
preventDefault
();
// 阻止默认粘贴行为
// 只取第一个支持的文件(单文件限制)
const
file
=
supportedItems
[
0
].
getAsFile
();
if
(
file
)
{
return
await
addAttachment
(
file
,
config
);
}
}
return
false
;
};
// 预览附件
const
previewAttachment
=
(
attachment
:
Attachment
)
=>
{
// 优先使用后台返回的URL,如果没有则使用本地预览URL
const
url
=
attachment
.
attachmentPath
||
attachment
.
previewUrl
;
if
(
attachment
.
attachmentType
.
startsWith
(
'
image/
'
))
{
// 图片预览 - 新窗口打开URL
window
.
open
(
url
,
'
_blank
'
);
}
else
{
// 其他文件类型,可以下载预览
if
(
attachment
.
attachmentPath
)
{
// 如果有后台URL,直接打开
window
.
open
(
url
,
'
_blank
'
);
}
else
{
// 如果没有后台URL,使用本地文件下载
const
localUrl
=
URL
.
createObjectURL
(
attachment
.
file
);
const
a
=
document
.
createElement
(
'
a
'
);
a
.
href
=
localUrl
;
a
.
download
=
attachment
.
attachmentName
;
a
.
click
();
URL
.
revokeObjectURL
(
localUrl
);
}
}
};
// 删除附件
const
removeAttachment
=
(
attachmentId
:
string
)
=>
{
const
attachment
=
attachments
.
value
.
find
(
a
=>
a
.
id
===
attachmentId
);
if
(
attachment
&&
attachment
.
previewUrl
)
{
URL
.
revokeObjectURL
(
attachment
.
previewUrl
);
}
attachments
.
value
=
attachments
.
value
.
filter
(
a
=>
a
.
id
!==
attachmentId
);
};
// 清空所有附件
const
clearAttachments
=
()
=>
{
attachments
.
value
.
forEach
(
attachment
=>
{
if
(
attachment
.
previewUrl
)
{
URL
.
revokeObjectURL
(
attachment
.
previewUrl
);
}
});
attachments
.
value
=
[];
};
/**
* 处理粘贴事件,自动识别并上传附件
*/
const
handlePaste
=
async
(
event
:
ClipboardEvent
,
config
?:
UploadConfig
):
Promise
<
boolean
>
=>
{
return
await
addAttachmentFromPaste
(
event
,
config
);
};
/**
* 处理文件选择事件
*/
const
handleFileSelect
=
async
(
event
:
Event
,
config
?:
UploadConfig
):
Promise
<
boolean
>
=>
{
const
input
=
event
.
target
as
HTMLInputElement
;
if
(
!
input
.
files
||
input
.
files
.
length
===
0
)
return
false
;
const
file
=
input
.
files
[
0
];
return
await
addAttachment
(
file
,
config
);
};
/**
* 触发文件选择器
*/
const
triggerFileInput
=
(
fileInput
:
HTMLInputElement
|
null
)
=>
{
if
(
fileInput
)
{
fileInput
.
click
();
}
};
return
{
// 响应式数据
attachments
,
hasAttachment
,
currentAttachment
,
isUploading
,
// 核心方法
getFileTypeIcon
,
formatFileSize
,
getAcceptTypes
,
addAttachment
,
addAttachmentFromPaste
,
previewAttachment
,
removeAttachment
,
clearAttachments
,
// 事件处理方法
handlePaste
,
handleFileSelect
,
triggerFileInput
};
}
\ No newline at end of file
src/views/components/hooks/useFeedback.ts
0 → 100644
View file @
796092f1
import
{
ref
}
from
'
vue
'
;
import
{
message
}
from
'
ant-design-vue
'
;
import
type
{
Message
}
from
'
../utils/contentTemplateService
'
;
// 反馈弹窗相关数据接口
export
interface
FeedbackModalData
{
visible
:
boolean
;
recordId
:
string
;
type
:
'
up
'
|
'
down
'
;
// 反馈类型:up=赞,down=踩
msg
?:
any
;
// 保存对应的消息对象,避免重复查找
}
// 反馈提交数据接口
export
interface
FeedbackSubmitData
{
recordId
:
string
;
comment
?:
string
;
// 可选,踩的时候需要
type
:
'
up
'
|
'
down
'
;
// 反馈类型:up=赞,down=踩
}
// 基础配置接口
interface
BaseConfig
{
apiBaseUrl
?:
string
;
userToken
?:
string
;
appCode
?:
string
;
}
/**
* 赞踩功能hooks
* 使用同一个接口,只是类型不同,踩多一个理由
*/
export
function
useFeedback
(
baseConfig
?:
BaseConfig
)
{
// 反馈弹窗相关数据
const
feedbackModalData
=
ref
<
FeedbackModalData
>
({
visible
:
false
,
recordId
:
''
,
type
:
'
up
'
});
/**
* 统一的反馈接口
*/
const
submitFeedbackApi
=
async
(
feedbackData
:
FeedbackSubmitData
)
=>
{
// 使用传入的配置,如果没有则使用默认值
const
config
=
baseConfig
||
{};
const
{
userToken
,
appCode
}
=
config
||
{};
const
response
=
await
fetch
(
`
${
import
.
meta
.
env
.
VITE_SSE_PATH
}
/ask/comment`
,
{
method
:
'
POST
'
,
headers
:
{
'
Content-Type
'
:
'
application/json
'
,
'
x-app-code
'
:
appCode
||
''
,
'
token
'
:
userToken
||
''
,
'
x-session-id
'
:
userToken
||
''
,
}
as
HeadersInit
,
body
:
JSON
.
stringify
({
...
feedbackData
})
});
const
result
=
await
response
.
json
();
if
(
result
.
code
===
0
)
{
return
result
;
}
else
{
throw
new
Error
(
result
.
message
||
'
反馈提交失败
'
);
}
};
/**
* 点赞消息
*/
const
likeMessage
=
async
(
msg
:
Message
)
=>
{
if
(
msg
.
upCount
>
0
)
{
message
.
warning
(
'
您已经点过赞了
'
);
return
;
}
try
{
// 调用统一的反馈接口,type='up'
let
result
=
await
submitFeedbackApi
({
recordId
:
msg
.
recordId
,
type
:
'
up
'
});
// 更新前端状态
if
(
result
.
code
===
0
)
{
msg
.
upCount
=
result
.
data
.
up_count
;
message
.
success
(
'
感谢您的反馈!
'
);
// 提交成功后关闭弹窗
closeFeedbackModal
();
}
message
.
success
(
'
点赞成功
'
);
}
catch
(
error
)
{
console
.
error
(
'
点赞失败:
'
,
error
);
message
.
error
(
'
点赞失败,请稍后重试
'
);
}
};
/**
* 踩消息
*/
const
dislikeMessage
=
async
(
msg
:
Message
)
=>
{
if
(
msg
.
downCount
>
0
)
{
message
.
warning
(
'
您已经踩过了
'
);
return
;
}
// 直接显示反馈弹窗,让用户先输入理由,并保存msg对象
feedbackModalData
.
value
=
{
visible
:
true
,
recordId
:
msg
.
recordId
,
type
:
'
down
'
,
msg
:
msg
// 保存消息对象,避免重复查找
};
};
/**
* 处理反馈提交(踩的时候输入理由)
*/
const
submitFeedback
=
async
(
comment
:
string
)
=>
{
const
{
recordId
,
type
,
msg
}
=
feedbackModalData
.
value
;
try
{
// 调用统一的反馈接口,包含用户输入的理由
let
result
=
await
submitFeedbackApi
({
recordId
,
comment
,
type
});
// 更新前端状态
if
(
result
.
code
===
0
)
{
msg
.
downCount
=
result
.
data
.
down_count
;
message
.
success
(
'
感谢您的反馈!
'
);
// 提交成功后关闭弹窗
closeFeedbackModal
();
}
}
catch
(
error
)
{
message
.
error
(
'
反馈提交失败,请稍后重试
'
);
}
};
/**
* 关闭反馈弹窗
*/
const
closeFeedbackModal
=
()
=>
{
feedbackModalData
.
value
.
visible
=
false
;
};
/**
* 打开反馈弹窗
*/
const
openFeedbackModal
=
(
recordId
:
string
,
type
:
'
up
'
|
'
down
'
)
=>
{
feedbackModalData
.
value
=
{
visible
:
true
,
recordId
,
type
};
};
return
{
// 响应式数据
feedbackModalData
,
// 操作方法
likeMessage
,
dislikeMessage
,
submitFeedback
,
closeFeedbackModal
,
openFeedbackModal
};
}
\ No newline at end of file
src/views/components/hooks/useMessageActions.ts
0 → 100644
View file @
796092f1
import
{
type
Ref
}
from
'
vue
'
;
import
{
message
as
antdMessage
}
from
'
ant-design-vue
'
;
import
type
{
Message
,
MessageBlock
}
from
'
../utils/contentTemplateService
'
;
/**
* 消息操作hooks
* 封装了复制、消息点击等相关逻辑
*/
export
function
useMessageActions
(
messageText
:
Ref
<
string
>
)
{
/**
* 处理消息点击 - 将消息内容放到输入框
*/
const
handleMessageClick
=
(
message
:
Message
,
block
:
MessageBlock
,
textarea
:
HTMLTextAreaElement
|
null
)
=>
{
// 只处理文字消息,不处理音频、图表等特殊消息
if
(
block
.
audioData
||
block
.
chartData
)
{
return
;
}
try
{
// 提取消息中的文本内容
let
textToInput
=
''
;
if
(
message
.
contentBlocks
&&
message
.
contentBlocks
.
length
>
0
)
{
message
.
contentBlocks
.
forEach
(
block
=>
{
// 跳过非文字内容块
if
(
block
.
audioData
||
block
.
chartData
)
{
return
;
}
if
(
block
.
content
)
{
// 移除HTML标签,只保留纯文本
const
textContent
=
block
.
content
.
replace
(
/<
[^
>
]
*>/g
,
''
).
trim
();
if
(
textContent
)
{
textToInput
+=
textContent
+
'
\n
'
;
}
}
});
}
// 如果没有内容,使用原始内容
if
(
!
textToInput
.
trim
()
&&
message
.
originalContent
)
{
textToInput
=
message
.
originalContent
;
}
if
(
textToInput
.
trim
())
{
// 将内容设置到输入框
messageText
.
value
=
textToInput
.
trim
();
// 自动调整输入框高度
adjustTextareaHeight
(
textarea
);
// 聚焦到输入框
if
(
textarea
)
{
textarea
.
focus
();
}
// 提示用户
antdMessage
.
success
(
'
消息内容已放入输入框
'
);
}
}
catch
(
error
)
{
console
.
error
(
'
处理消息点击失败:
'
,
error
);
}
};
/**
* 复制消息内容
*/
const
handleCopy
=
async
(
msg
:
Message
)
=>
{
try
{
let
textToCopy
=
''
;
// 遍历所有内容块
msg
.
contentBlocks
.
forEach
(
block
=>
{
if
(
block
.
chartData
)
{
// 如果是表格内容,复制description
textToCopy
+=
block
.
chartData
.
description
||
''
;
}
else
if
(
block
.
content
)
{
// 检查是否是iframe内容
if
(
block
.
content
.
includes
(
'
message-iframe
'
))
{
// 提取iframe的src属性
const
srcMatch
=
block
.
content
.
match
(
/src="
([^
"
]
+
)
"/
);
if
(
srcMatch
&&
srcMatch
[
1
])
{
// 添加API基础URL到src前面
const
fullSrc
=
import
.
meta
.
env
.
VITE_API_BASE_URL
+
srcMatch
[
1
];
textToCopy
+=
fullSrc
+
'
\n
'
;
}
}
else
{
// 其他内容复制content
// 移除HTML标签,保留纯文本
const
textContent
=
block
.
content
.
replace
(
/<
[^
>
]
*>/g
,
''
).
trim
();
if
(
textContent
)
{
textToCopy
+=
textContent
+
'
\n
'
;
}
}
}
});
// 如果没有内容,使用原始内容
if
(
!
textToCopy
.
trim
()
&&
msg
.
originalContent
)
{
textToCopy
=
msg
.
originalContent
;
}
if
(
textToCopy
.
trim
())
{
// 使用Clipboard API复制到剪贴板
await
navigator
.
clipboard
.
writeText
(
textToCopy
.
trim
());
antdMessage
.
success
(
'
内容已复制到剪贴板
'
);
}
else
{
antdMessage
.
warning
(
'
没有可复制的内容
'
);
}
}
catch
(
error
)
{
console
.
error
(
'
复制失败:
'
,
error
);
antdMessage
.
error
(
'
复制失败,请手动复制
'
);
}
};
/**
* 自动调整文本区域高度
*/
const
adjustTextareaHeight
=
(
textarea
:
HTMLTextAreaElement
|
null
)
=>
{
if
(
textarea
)
{
textarea
.
style
.
height
=
'
auto
'
;
textarea
.
style
.
height
=
`
${
Math
.
min
(
textarea
.
scrollHeight
,
52
)}
px`
;
}
};
/**
* 提取消息中的纯文本内容
*/
const
extractMessageText
=
(
message
:
Message
):
string
=>
{
let
text
=
''
;
if
(
message
.
contentBlocks
&&
message
.
contentBlocks
.
length
>
0
)
{
message
.
contentBlocks
.
forEach
(
block
=>
{
// 跳过非文字内容块
if
(
block
.
audioData
||
block
.
chartData
)
{
return
;
}
if
(
block
.
content
)
{
// 移除HTML标签,只保留纯文本
const
textContent
=
block
.
content
.
replace
(
/<
[^
>
]
*>/g
,
''
).
trim
();
if
(
textContent
)
{
text
+=
textContent
+
'
\n
'
;
}
}
});
}
// 如果没有内容,使用原始内容
if
(
!
text
.
trim
()
&&
message
.
originalContent
)
{
text
=
message
.
originalContent
;
}
return
text
.
trim
();
};
/**
* 检查消息是否可点击(包含可复制的文本内容)
*/
const
isMessageClickable
=
(
message
:
Message
,
block
:
MessageBlock
):
boolean
=>
{
// 如果有音频、图表等特殊内容,不可点击
if
(
block
.
audioData
||
block
.
chartData
)
{
return
false
;
}
// 检查是否有可复制的文本内容
return
extractMessageText
(
message
).
length
>
0
;
};
return
{
// 操作方法
handleMessageClick
,
handleCopy
,
adjustTextareaHeight
,
// 工具方法
extractMessageText
,
isMessageClickable
};
}
\ No newline at end of file
src/views/components/style.less
View file @
796092f1
...
...
@@ -191,7 +191,7 @@ li {
// 输入容器
.chat-input-container {
padding:
4
0px 0 30px;
padding:
1
0px 0 30px;
flex-shrink: 0;
}
}
...
...
@@ -275,7 +275,7 @@ li {
}
}
.message-content {
padding: 10px;
border-radius: 4px;
position: relative;
white-space: pre-wrap;
...
...
@@ -286,6 +286,13 @@ li {
:deep(.message-text) {
font-size: 16px;
}
:deep(.message-question) {
font-size: 16px;
background: #5B8AFE;
color: @white;
padding: 10px;
border-radius: 4px;
}
}
}
...
...
@@ -298,11 +305,6 @@ li {
box-shadow:none;
}
.message.sent .message-content {
background: #5B8AFE;
color: @white;
}
// 图表块样式
.chart-block {
margin: 20px 0px 8px;
...
...
@@ -358,6 +360,46 @@ li {
background: #fff2f0;
color: @error-color;
}
// 禁用状态
&.disabled {
opacity: 0.5;
cursor: not-allowed;
&:hover {
background: @gray-2;
color: @gray-7;
transform: none;
}
}
// 计数徽章
.count-badge {
position: absolute;
top: -4px;
right: -4px;
background: @primary-color;
color: white;
border-radius: 8px;
font-size: 10px;
min-width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
font-weight: 600;
}
// 赞按钮的计数徽章
&.like-btn .count-badge {
background: @success-color;
}
// 踩按钮的计数徽章
&.dislike-btn .count-badge {
background: @error-color;
}
}
}
}
...
...
@@ -1096,13 +1138,203 @@ li {
font-style: italic;
color: @gray-6;
}
}
}
// =============================================
// 附件上传样式
// =============================================
// 附件预览容器
.attachments-preview-container {
width: fit-content;
}
.attachments-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.attachment-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background-color: @gray-1;
border-radius: 8px;
border: 1px solid @gray-3;
}
.attachment-preview {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.preview-image {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 4px;
}
.file-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background-color: @blue-light-1;
border-radius: 4px;
font-size: 18px;
}
.attachment-info {
display: flex;
flex-direction: column;
margin-right: 12px;
}
.attachment-name {
font-size: 14px;
font-weight: 500;
color: @gray-7;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attachment-size {
font-size: 12px;
color: @gray-5;
}
.attachment-actions {
display: flex;
gap: 8px;
}
.action-btn {
border:none;
border-radius: 4px;
background-color: @white;
cursor: pointer;
font-size: 16px;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.action-btn:hover {
background-color: @gray-2;
}
.preview-btn {
color: @primary-color;
border-color: @primary-color;
}
.preview-btn:hover {
background-color: @blue-light-1;
}
.remove-btn {
color: @error-color;
border-color: @error-color;
}
.remove-btn:hover {
background-color: @error-bg;
}
// 上传按钮
.upload-wrapper {
display: flex;
align-items: center;
}
.upload-button {
padding: 8px;
border: none;
background: transparent;
cursor: pointer;
color: @gray-5;
transition: color 0.2s;
position: absolute;
right: 92px; // 放在语音识别按钮左边
top: 50%;
transform: translateY(-50%);
z-index: 10;
font-size: 18px;
color: @primary-color;
}
.upload-button:disabled {
color: @gray-4;
cursor: not-allowed;
}
// 消息中的附件
.attachment-block {
margin: 8px 0;
display: flex;
flex-direction: column;
align-items: flex-end;
}
.attachment-display {
display: inline-block;
max-width: 100%;
margin-bottom:8px;
}
.message-attachment-image {
max-width: 200px;
max-height: 200px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.file-display {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background-color: @gray-1;
border-radius: 8px;
border: 1px solid @gray-3;
}
.file-icon {
font-size: 24px;
}
.file-info {
display: flex;
flex-direction: column;
}
.file-name {
font-size: 14px;
font-weight: 500;
color: @gray-7;
}
.file-size {
font-size: 12px;
color: @gray-5;
}
// 图片样式
img {
max-width: 100%;
height: auto;
border-radius: 4px;
margin-bottom: 8px;
}
// Markdown图片容器样式
...
...
@@ -1278,8 +1510,8 @@ li {
text-align: left;
}
}
}
}
...
...
@@ -1287,7 +1519,6 @@ li {
.chat-input {
display: flex;
align-items: flex-end;
gap: 8px;
textarea {
flex: 1;
...
...
@@ -1350,6 +1581,9 @@ li {
border-top: 1px solid #e8f2f1; // 保持灰色系
background: #FCFCFC; // 保持灰色系
}
.attachments-preview-container{
padding:10px;
}
}
}
...
...
@@ -1365,7 +1599,6 @@ li {
min-width: 60px;
}
}
.table-title {
font-size: 16px;
font-weight: bold;
...
...
@@ -1421,7 +1654,6 @@ li {
}
}
}
.avatar-container {
display: none;
}
...
...
@@ -1434,6 +1666,9 @@ li {
.chat-input-container{
padding:12px;
}
.attachments-preview-container{
padding:12px;
}
}
}
...
...
src/views/components/utils/contentTemplateService.ts
View file @
796092f1
...
...
@@ -11,6 +11,7 @@ const CHART_TYPES = {
// 内容模板类型定义
export
interface
ContentTemplates
{
question
:
(
content
:
string
)
=>
string
;
text
:
(
content
:
string
)
=>
string
;
thinking
:
(
content
:
string
)
=>
string
;
error
:
(
content
:
string
)
=>
string
;
...
...
@@ -31,11 +32,17 @@ export interface MessageBlock {
chartType
?:
number
|
string
;
audioData
?:
{
src
:
string
;
durationTime
:
string
;
durationTime
?:
number
|
string
;
};
thinkingTime
?:
number
;
thinkingTimeText
?:
string
;
}
attachmentData
?:{
attachmentName
?:
string
;
attachmentPath
?:
string
;
attachmentType
?:
string
;
attachmentSize
?:
number
;
}
}
// 消息类型定义
export
interface
Message
{
...
...
@@ -57,6 +64,9 @@ export interface Message {
// 推荐会话相关属性
recommendations
?:
any
[];
showRecommendations
?:
boolean
;
// 赞踩相关属性
upCount
:
number
;
// 点赞数量
downCount
:
number
;
// 踩数量
}
// SSE数据类型定义
...
...
@@ -86,7 +96,8 @@ function isLastBlockText(blocks: MessageBlock[]): boolean {
return
!!
lastBlock
.
content
&&
!
lastBlock
.
audioData
&&
!
lastBlock
.
chartData
&&
!
lastBlock
.
thinkContent
;
!
lastBlock
.
thinkContent
&&
!
lastBlock
.
attachmentData
;
}
// 获取最后一个普通文本块的索引
...
...
@@ -96,7 +107,8 @@ function getLastTextBlockIndex(blocks: MessageBlock[]): number {
if
(
!!
block
.
content
&&
!
block
.
audioData
&&
!
block
.
chartData
&&
!
block
.
thinkContent
)
{
!
block
.
thinkContent
&&
!
block
.
attachmentData
)
{
return
i
;
}
}
...
...
@@ -134,6 +146,10 @@ export class ContentTemplateService {
// 创建内容模板生成器
private
createTemplates
():
ContentTemplates
{
return
{
// 普通文本
question
:
(
content
:
string
)
=>
{
return
`<div class="message-question">
${
content
}
</div>`
;
},
// 普通文本
text
:
(
content
:
string
)
=>
{
return
`<div class="message-text">
${
content
}
</div>`
;
...
...
@@ -231,6 +247,9 @@ export class ContentTemplateService {
public
generateTextTemplate
(
content
:
string
):
string
{
return
this
.
templates
.
text
(
content
);
}
public
generateQuestionTemplate
(
content
:
string
):
string
{
return
this
.
templates
.
question
(
content
);
}
public
generateThinkingTemplate
(
content
:
string
):
string
{
return
this
.
templates
.
thinking
(
content
);
...
...
@@ -567,7 +586,7 @@ export class ContentTemplateService {
let
date
=
dayjs
(
data
.
startTime
).
format
(
'
YYYY-MM-DD HH:mm:ss
'
);
// 处理问题消息
if
(
data
.
question
||
data
.
audioPath
)
{
if
(
data
.
question
||
data
.
audioPath
||
data
.
attachmentPath
)
{
// 创建基础消息结构
const
message
=
{
messageType
:
'
sent
'
as
const
,
...
...
@@ -577,39 +596,57 @@ export class ContentTemplateService {
completionTokens
:
0
,
totalTokens
:
0
,
date
,
contentBlocks
:
[]
as
MessageBlock
[]
contentBlocks
:
[]
as
MessageBlock
[],
// 处理赞踩状态
upCount
:
data
.
upCount
||
0
,
downCount
:
data
.
downCount
||
0
};
// 使用switch语句处理不同类型的消息
switch
(
true
)
{
case
!!
data
.
audioPath
:
// 音频消息
message
.
contentBlocks
.
push
({
audioData
:
{
src
:
data
.
audioPath
,
durationTime
:
data
.
audioTime
||
'
0"
'
},
content
:
''
,
thinkContent
:
''
,
hasThinkBox
:
false
,
thinkBoxExpanded
:
false
,
});
break
;
case
!!
data
.
question
:
// 文本消息
message
.
contentBlocks
.
push
({
content
:
this
.
templates
.
text
(
data
.
question
),
thinkContent
:
''
,
hasThinkBox
:
false
,
thinkBoxExpanded
:
false
,
});
break
;
default
:
// 其他类型的消息(未来扩展)
break
;
// 处理不同类型的消息(允许多种类型共存)
const
contentBlocks
:
MessageBlock
[]
=
[];
// 处理音频消息
if
(
data
.
audioPath
)
{
contentBlocks
.
push
({
audioData
:
{
src
:
data
.
audioPath
,
durationTime
:
data
.
audioTime
||
'
0"
'
},
content
:
''
,
thinkContent
:
''
,
hasThinkBox
:
false
,
thinkBoxExpanded
:
false
,
});
}
// 处理附件消息
if
(
data
.
attachmentPath
)
{
contentBlocks
.
push
({
attachmentData
:
{
attachmentName
:
data
.
attachmentName
||
''
,
attachmentPath
:
data
.
attachmentPath
,
attachmentType
:
data
.
attachmentType
||
''
,
attachmentSize
:
data
.
attachmentSize
||
0
,
},
content
:
''
,
thinkContent
:
''
,
hasThinkBox
:
false
,
thinkBoxExpanded
:
false
,
});
}
// 处理文本消息(如果有文本内容)
if
(
data
.
question
)
{
contentBlocks
.
push
({
content
:
this
.
templates
.
question
(
data
.
question
),
thinkContent
:
''
,
hasThinkBox
:
false
,
thinkBoxExpanded
:
false
,
});
}
// 将所有内容块添加到消息中
message
.
contentBlocks
=
contentBlocks
;
result
.
push
(
message
);
}
...
...
@@ -625,6 +662,9 @@ export class ContentTemplateService {
totalTokens
:
0
,
contentBlocks
:
[],
date
,
// 处理赞踩状态
upCount
:
data
.
upCount
||
0
,
downCount
:
data
.
downCount
||
0
};
let
currentThinkingMode
=
false
;
...
...
src/views/components/utils/sseService.ts
View file @
796092f1
...
...
@@ -10,9 +10,11 @@ export interface SSEData {
// SSE服务配置
export
interface
SSEServiceConfig
{
apiBaseUrl
:
string
;
appCode
:
string
;
token
:
string
;
baseConfig
:{
apiBaseUrl
:
string
;
userToken
:
string
;
appCode
:
string
;
};
params
:
{
stage
?:
string
;
appId
?:
string
;
...
...
@@ -54,9 +56,9 @@ export class SSEService {
const
url
=
`
${
import
.
meta
.
env
.
VITE_SSE_PATH
}
/sse/join/
${
this
.
config
.
params
?.
stage
||
''
}
?app-id=
${
this
.
config
.
params
?.
appId
||
''
}
&dialog-session-id=
${
dialogSessionId
||
''
}
`
;
this
.
eventSource
=
new
EventSourcePolyfill
(
url
,
{
headers
:
{
Token
:
this
.
config
.
t
oken
||
''
,
'
x-session-id
'
:
this
.
config
.
t
oken
||
''
,
'
x-app-code
'
:
this
.
config
.
appCode
||
''
,
Token
:
this
.
config
.
baseConfig
.
userT
oken
||
''
,
'
x-session-id
'
:
this
.
config
.
baseConfig
.
userT
oken
||
''
,
'
x-app-code
'
:
this
.
config
.
baseConfig
.
appCode
||
''
,
},
withCredentials
:
true
,
connectionTimeout
:
60000
,
...
...
vite.config.js
View file @
796092f1
...
...
@@ -53,6 +53,11 @@ export default defineConfig(({ mode }) => {
changeOrigin
:
true
,
secure
:
false
,
},
'
/cfile
'
:
{
target
:
apiBaseUrl
,
changeOrigin
:
true
,
secure
:
false
,
},
'
/WeChatOauth2
'
:
{
target
:
apiBaseUrl
,
changeOrigin
:
true
,
...
...
Write
Preview
Supports
Markdown
0%
Try again
or
attach a new 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