- ws.ts 心跳 pong 闭环 + useAiChat 看门狗治半连接挂死 - pages 新增 settings/ + chat/conversations 更新 + types events/relay + mdRenderer + tsconfig
233 lines
5.9 KiB
Vue
233 lines
5.9 KiB
Vue
<script setup lang="ts">
|
|
import { ref, watch } from 'vue'
|
|
import { onPullDownRefresh } from '@dcloudio/uni-app'
|
|
import { useAiChat } from '@/composables/useAiChat'
|
|
import type { Conversation } from '@/types/events'
|
|
|
|
const ai = useAiChat()
|
|
|
|
/**
|
|
* mp-weixin 模板对嵌套在对象中的 reactive/ref 响应性追踪有问题。
|
|
* 用本地 ref + watch 同步,确保模板直接拿到响应式数组。
|
|
*/
|
|
const conversations = ref<Conversation[]>([])
|
|
/**
|
|
* 监听 ai.conversations.length 变化(比 deep watch 更可靠)。
|
|
* 用 `[...ai.conversations]` 创建普通数组赋值给 ref,确保每次变化都触发模板更新。
|
|
*/
|
|
watch(() => ai.conversations.value.length, () => {
|
|
conversations.value = [...ai.conversations.value]
|
|
}, { immediate: true })
|
|
|
|
/** 点击会话项切换 */
|
|
function handleSelect(convId: string): void {
|
|
ai.switchConversation(convId)
|
|
// 切换后跳回对话页
|
|
uni.switchTab({ url: '/pages/chat/index' })
|
|
}
|
|
|
|
/** 新建会话 */
|
|
function handleNew(): void {
|
|
ai.newConversation()
|
|
uni.switchTab({ url: '/pages/chat/index' })
|
|
}
|
|
|
|
/** 长按会话项重命名(对齐桌面端改会话名) */
|
|
function handleRename(conv: Conversation): void {
|
|
uni.showModal({
|
|
title: '重命名会话',
|
|
editable: true,
|
|
placeholderText: '输入新名称',
|
|
content: conv.title || '',
|
|
success: (r) => {
|
|
if (r.confirm && r.content) {
|
|
const newTitle = r.content.trim()
|
|
if (newTitle && newTitle !== conv.title) {
|
|
ai.renameConversation(conv.id, newTitle)
|
|
}
|
|
}
|
|
},
|
|
})
|
|
}
|
|
|
|
/** 下拉刷新:发 list_conversations 同步 device 会话列表(F-#95)。
|
|
* 命令异步回流经 AiConversationList 事件替换 ai.conversations(handleEvent),
|
|
* 此处仅发命令 + 收尾停止下拉动画(列表更新非同步返回,故立即停止动画即可)。 */
|
|
onPullDownRefresh(() => {
|
|
ai.refreshConversations()
|
|
uni.stopPullDownRefresh()
|
|
})
|
|
|
|
/** 格式化时间戳为可读字符串 */
|
|
function formatTime(ts?: number): string {
|
|
if (!ts) return ''
|
|
const d = new Date(ts)
|
|
const mm = String(d.getMonth() + 1).padStart(2, '0')
|
|
const dd = String(d.getDate()).padStart(2, '0')
|
|
const hh = String(d.getHours()).padStart(2, '0')
|
|
const mi = String(d.getMinutes()).padStart(2, '0')
|
|
return `${mm}-${dd} ${hh}:${mi}`
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<view class="conv-page">
|
|
<!-- 顶部新建按钮 -->
|
|
<view class="new-bar" @click="handleNew">
|
|
<text class="new-icon">+</text>
|
|
<text class="new-text">新建会话</text>
|
|
</view>
|
|
|
|
<!-- 会话列表 -->
|
|
<scroll-view class="conv-list" scroll-y>
|
|
<!-- 加载中骨架屏 -->
|
|
<view v-if="conversations.length === 0 && ai.loading.value" class="conv-skeleton">
|
|
<view v-for="i in 5" :key="i" class="conv-sk-item">
|
|
<view class="conv-sk-line conv-sk-w70"></view>
|
|
<view class="conv-sk-line conv-sk-w40"></view>
|
|
</view>
|
|
</view>
|
|
<view v-else-if="conversations.length === 0" class="empty-hint">
|
|
<text>暂无历史会话</text>
|
|
<text class="empty-sub">在桌面端创建后,此处同步</text>
|
|
</view>
|
|
|
|
<view
|
|
v-for="conv in conversations"
|
|
:key="conv.id"
|
|
class="conv-item"
|
|
:class="{ active: conv.id === ai.activeConversationId.value }"
|
|
@click="handleSelect(conv.id)"
|
|
@longpress="handleRename(conv)"
|
|
>
|
|
<view class="conv-main">
|
|
<text class="conv-title">{{ conv.title || '(未命名会话)' }}</text>
|
|
<text v-if="conv.lastMessage" class="conv-last">{{ conv.lastMessage }}</text>
|
|
</view>
|
|
<text v-if="conv.updatedAt" class="conv-time">{{ formatTime(conv.updatedAt) }}</text>
|
|
</view>
|
|
<view v-if="conversations.length > 0" class="list-hint">
|
|
<text>长按会话可重命名</text>
|
|
</view>
|
|
</scroll-view>
|
|
</view>
|
|
</template>
|
|
|
|
<style lang="scss" scoped>
|
|
.conv-page {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100vh;
|
|
background-color: $uni-bg-color;
|
|
}
|
|
|
|
.new-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 24rpx;
|
|
background-color: $uni-bg-color-dark;
|
|
border-bottom: 1rpx solid $uni-border-color;
|
|
}
|
|
|
|
.new-icon {
|
|
font-size: 40rpx;
|
|
color: $uni-color-primary;
|
|
margin-right: 16rpx;
|
|
}
|
|
|
|
.new-text {
|
|
font-size: $uni-font-size-base;
|
|
color: $uni-color-primary;
|
|
}
|
|
|
|
.conv-list {
|
|
flex: 1;
|
|
}
|
|
|
|
.empty-hint {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
padding: 200rpx 24rpx;
|
|
text {
|
|
color: $uni-text-color-disable;
|
|
font-size: $uni-font-size-base;
|
|
}
|
|
.empty-sub {
|
|
margin-top: 12rpx;
|
|
font-size: $uni-font-size-sm;
|
|
}
|
|
}
|
|
|
|
.conv-item {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 24rpx;
|
|
border-bottom: 1rpx solid $uni-border-color;
|
|
&.active {
|
|
background-color: $uni-bg-color-light;
|
|
border-left: 6rpx solid $uni-color-primary;
|
|
}
|
|
}
|
|
|
|
.conv-main {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.conv-title {
|
|
font-size: $uni-font-size-base;
|
|
color: $uni-text-color;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.conv-last {
|
|
margin-top: 8rpx;
|
|
font-size: $uni-font-size-sm;
|
|
color: $uni-text-color-grey;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.conv-time {
|
|
font-size: $uni-font-size-sm;
|
|
color: $uni-text-color-disable;
|
|
margin-left: 16rpx;
|
|
}
|
|
.list-hint {
|
|
padding: 24rpx;
|
|
text-align: center;
|
|
}
|
|
.list-hint text {
|
|
color: $uni-text-color-disable;
|
|
font-size: $uni-font-size-sm;
|
|
}
|
|
/* 骨架屏(下拉刷新/冷启动占位) */
|
|
.conv-skeleton {
|
|
padding: 24rpx;
|
|
}
|
|
.conv-sk-item {
|
|
margin-bottom: 32rpx;
|
|
padding: 24rpx;
|
|
}
|
|
.conv-sk-line {
|
|
height: 24rpx;
|
|
margin: 8rpx 0;
|
|
background: linear-gradient(90deg, $uni-bg-color-light 25%, #3a3a3a 50%, $uni-bg-color-light 75%);
|
|
background-size: 200% 100%;
|
|
animation: conv-sk-shimmer 1.5s infinite;
|
|
border-radius: 8rpx;
|
|
}
|
|
.conv-sk-w70 { width: 70%; }
|
|
.conv-sk-w40 { width: 40%; }
|
|
@keyframes conv-sk-shimmer {
|
|
0% { background-position: 200% 0; }
|
|
100% { background-position: -200% 0; }
|
|
}
|
|
</style>
|