完善 ChatView 页面功能与用户交互逻辑

- 增加请求状态管理,区分 RUNNING 和 DONE 状态
- 优化消息列表归一化处理逻辑,提高代码可读性
- 添加按键事件支持 (Ctrl+Enter) 以改善发送消息体验
- 更新输入框与发送按钮的禁用逻辑,明确状态反馈
- 提升 SSE 连接恢复的安全性,增加鉴权检查
- 优化界面显示,新增快捷键提示及样式改进
- 移除 package-lock.json 中多余的 peer 标记
This commit is contained in:
2025-12-18 17:50:02 +08:00
parent 98fbc46021
commit afb166dddf
5 changed files with 470 additions and 26 deletions

View File

@@ -19,6 +19,7 @@ const inputText = ref('')
const uploadFiles = ref([])
const fileList = ref([])
const sending = ref(false)
const runStatus = ref('DONE')
const loadingMessages = ref(false)
const errorMessage = ref('')
const showJumpDown = ref(false)
@@ -72,6 +73,48 @@ const normalizeMessage = (msg = {}) => {
}
}
const extractRunStatus = (msg = {}) => {
const status = msg.payload?.status || msg.content
return status === 'RUNNING' || status === 'DONE' ? status : null
}
const applyRunStatus = (msg = {}) => {
if (msg.type !== 'run.status') return false
const status = extractRunStatus(msg)
if (!status) return false
runStatus.value = status
if (typeof msg.seq === 'number') {
lastSeq.value = Math.max(lastSeq.value || 0, msg.seq)
summary.lastSeq = lastSeq.value
}
return true
}
const refreshLastSeq = (list = []) => {
const maxSeq = list.reduce((max, item) => {
const seq = typeof item.seq === 'number' ? item.seq : 0
return seq > max ? seq : max
}, lastSeq.value || 0)
lastSeq.value = maxSeq
summary.lastSeq = maxSeq
}
const normalizeMessageList = (list = []) => {
let latestStatus = runStatus.value
const normalized = []
list.forEach((msg) => {
if (msg?.type === 'run.status') {
const status = extractRunStatus(msg)
if (status) latestStatus = status
return
}
normalized.push(normalizeMessage(msg))
})
runStatus.value = latestStatus
refreshLastSeq(list)
return normalized
}
const upsertMessage = (msg) => {
const list = messagesState.value
const idx = list.findIndex(
@@ -106,6 +149,8 @@ const normalizedSummary = (data = {}) => ({
tags: data.last_message_type ? [data.last_message_type] : [],
})
const runActive = computed(() => runStatus.value === 'RUNNING')
const inputLocked = computed(() => summary.status === 'CLOSED')
const messages = computed(() => messagesState.value || [])
const handleFileChange = (uploadFile, uploadFilesList) => {
@@ -142,8 +187,19 @@ const handleFileRemove = (file, list) => {
uploadFiles.value = filtered.map(toAttachment)
}
const handleComposerKeydown = (event) => {
if (event.key === 'Enter' && event.ctrlKey) {
event.preventDefault()
handleSend()
}
}
const handleSend = () => {
if (sending.value) return
if (runActive.value) {
ElMessage.info('Agent 正在处理中,请稍候再试')
return
}
if (summary.status === 'CLOSED') {
ElMessage.warning('会话已归档,无法发送')
return
@@ -291,7 +347,7 @@ const fetchMessages = async () => {
}
)
const list = data.data || []
messagesState.value = list.map((msg) => normalizeMessage(msg))
messagesState.value = normalizeMessageList(list)
if (list.length) {
const last = list[list.length - 1]
lastSeq.value = last.seq || 0
@@ -331,6 +387,7 @@ const fetchSingleMessage = async (messageId) => {
{ headers: { Authorization: `Bearer ${token}` } }
)
const detail = data.data || data
if (applyRunStatus(detail)) return
const normalized = normalizeMessage(detail)
upsertMessage(normalized)
} catch (err) {
@@ -366,6 +423,26 @@ const stopSse = () => {
}
}
const checkSseAuth = async () => {
const token = localStorage.getItem('ars-token') || ''
if (!token) {
router.replace({ name: 'login' })
return false
}
try {
await axios.get(`${apiBase}/me`, {
headers: { Authorization: `Bearer ${token}` },
})
return true
} catch (err) {
if (err.response?.status === 401) {
router.replace({ name: 'login' })
return false
}
return true
}
}
const startSse = () => {
if (typeof window === 'undefined' || typeof EventSource === 'undefined')
return
@@ -391,6 +468,7 @@ const startSse = () => {
fetchSingleMessage(data.message_id)
return
}
if (applyRunStatus(data)) return
const normalized = normalizeMessage(data)
upsertMessage(normalized)
nextTick(() => scrollToBottom())
@@ -400,7 +478,10 @@ const startSse = () => {
}
es.onerror = () => {
stopSse()
sseRetryTimer.value = window.setTimeout(() => startSse(), 3000)
checkSseAuth().then((authed) => {
if (!authed) return
sseRetryTimer.value = window.setTimeout(() => startSse(), 3000)
})
}
eventSource.value = es
}
@@ -409,6 +490,7 @@ const applySession = () => {
if (!sessionId.value) return
stopSse()
lastSeq.value = 0
runStatus.value = 'DONE'
localStorage.setItem('ars-current-session', sessionId.value)
fetchSummary()
fetchMessages().then(() => {
@@ -566,6 +648,7 @@ onBeforeUnmount(() => {
multiple
accept="image/*,.pdf,.txt,.doc,.docx,.md"
:auto-upload="false"
:disabled="inputLocked"
:show-file-list="false"
:file-list="fileList"
:on-change="handleFileChange"
@@ -581,17 +664,24 @@ onBeforeUnmount(() => {
:autosize="{ minRows: 3, maxRows: 8 }"
class="composer-input"
:placeholder="summary.status === 'CLOSED' ? '会话已归档,无法继续发送' : '输入消息,支持粘贴大段文本与附件...'"
:disabled="summary.status === 'CLOSED'"
:disabled="inputLocked"
@keydown="handleComposerKeydown"
/>
<el-button
color="#1d4ed8"
class="send-btn"
:loading="sending"
:disabled="summary.status === 'CLOSED'"
@click="handleSend"
>
发送
</el-button>
<div class="send-wrap">
<el-button
color="#1d4ed8"
class="send-btn"
:loading="sending || runActive"
:disabled="inputLocked || runActive"
@click="handleSend"
>
发送
</el-button>
<span class="send-hint">
<span class="keycap">Ctrl</span> +
<span class="keycap">Enter</span> 发送
</span>
</div>
</div>
</div>
@@ -909,6 +999,32 @@ onBeforeUnmount(() => {
padding: 0 18px;
}
.send-wrap {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
.send-hint {
font-size: 12px;
color: #9ca3af;
letter-spacing: 0.02em;
}
.keycap {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px 6px;
border-radius: 8px;
border: 1px solid rgba(15, 23, 42, 0.12);
background: #ffffff;
color: #6b7280;
font-size: 11px;
font-weight: 600;
}
.pending-attachments {
padding: 10px 0 0;
border-top: 1px dashed rgba(0, 0, 0, 0.08);
@@ -989,5 +1105,10 @@ onBeforeUnmount(() => {
.send-btn {
width: 100%;
}
.send-wrap {
width: 100%;
align-items: stretch;
}
}
</style>