feat(chat): add OC status bar and delayed AI reply placeholder

linfangfang_branch
杨美曦 2 months ago
parent 5060dfbf2d
commit d095f484ab

@ -3,7 +3,10 @@
<div class="chat-header">
<div class="chat-title-only">
<div class="chat-title">{{ activeContact?.name || '选择联系人' }}</div>
<div class="chat-subtitle" v-if="activeContact">{{ activeContact.status || '线' }}</div>
<div class="chat-subtitle" v-if="activeContact">
<span class="status-dot" :class="statusClass"></span>
<span>{{ statusText }}</span>
</div>
</div>
<div class="header-actions">
<el-button text :icon="Message" @click="$emit('open-info')"></el-button>
@ -36,7 +39,7 @@
</template>
<script setup>
import { onMounted, onUpdated, ref, watch, nextTick, computed } from 'vue'
import { onMounted, onUpdated, ref, watch, nextTick, computed, onBeforeUnmount } from 'vue'
import { Message, Phone, VideoCamera } from '@element-plus/icons-vue'
import axios from 'axios'
@ -82,6 +85,85 @@ onUpdated(scrollToBottom)
watch(messages, scrollToBottom, { deep: true })
import { currentUser } from '../stores/userStore.js'
// activeContact.schedule
const statusText = ref('在线')
const statusClass = ref('online')
const currentActivity = ref('')
let statusTimer = null
function computeStatusFromSchedule(oc) {
try {
if (!oc || !Array.isArray(oc.schedule) || oc.schedule.length === 0) {
statusText.value = '在线'
statusClass.value = 'online'
currentActivity.value = ''
return
}
const now = new Date()
const nowT = now.getTime()
const todayStr = now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0') + '-' + String(now.getDate()).padStart(2,'0')
const todays = oc.schedule.filter(s => String(s.date || '').startsWith(todayStr))
if (!todays.length) {
statusText.value = '空闲'
statusClass.value = 'idle'
currentActivity.value = ''
return
}
for (const s of todays) {
const timeStr = (s.time || '00:00').slice(0,5)
const start = new Date(`${s.date || todayStr}T${timeStr}`)
const end = new Date(start.getTime() + (s.durationMinutes ? s.durationMinutes*60000 : 60*60000))
if (nowT >= start.getTime() - 30*60000 && nowT <= end.getTime() + 30*60000) {
const eventText = (s.event || '').toLowerCase()
if (/游泳|跑步|健身|运动|爬山|滑雪|球/ig.test(eventText)) {
statusText.value = `正在${s.event || '运动'}`
statusClass.value = 'busy'
currentActivity.value = s.event || '运动'
} else if (/会议|上班|工作|上课|学习/ig.test(eventText)) {
statusText.value = `忙碌:${s.event || ''}`
statusClass.value = 'busy'
currentActivity.value = s.event || '忙碌'
} else if (/休息|放松|休闲|看电影|娱乐/ig.test(eventText)) {
statusText.value = `休闲:${s.event || ''}`
statusClass.value = 'relax'
currentActivity.value = s.event || '休闲'
} else {
statusText.value = `忙碌:${s.event || ''}`
statusClass.value = 'busy'
currentActivity.value = s.event || '忙碌'
}
return
}
}
const upcoming = todays.find(s => {
const start = new Date(`${s.date || todayStr}T${(s.time||'00:00').slice(0,5)}`)
return start.getTime() > nowT
})
if (upcoming) {
statusText.value = `稍后:${upcoming.time || ''} ${upcoming.event || ''}`
statusClass.value = 'soon'
currentActivity.value = ''
} else {
statusText.value = '空闲'
statusClass.value = 'idle'
currentActivity.value = ''
}
} catch (e) {
statusText.value = '在线'
statusClass.value = 'online'
currentActivity.value = ''
}
}
watch(() => props.activeContact, (c) => {
computeStatusFromSchedule(c || {})
if (statusTimer) clearInterval(statusTimer)
statusTimer = setInterval(() => computeStatusFromSchedule(props.activeContact || {}), 60 * 1000)
}, { immediate: true })
onBeforeUnmount(() => {
if (statusTimer) clearInterval(statusTimer)
})
const handleSend = async () => {
if (!canSend.value) return
const userMsg = {
@ -106,6 +188,20 @@ const handleSend = async () => {
nextMessages = [...messages.value, aiMsg]
messages.value = nextMessages
emit('update:modelValue', nextMessages)
const isBusy = statusClass.value === 'busy' || statusText.value.startsWith('正在')
const delayMs = isBusy ? 5 * 60 * 1000 : 0 // 5
// ...
if (isBusy) {
const placeholder = {
...aiMsg,
text: `我正在${currentActivity.value || '忙碌'},请稍等`,
timestamp: Date.now()
}
//
messages.value = [...messages.value.slice(0, -1), placeholder]
emit('update:modelValue', messages.value)
// respond()
}
if (!API_KEY) {
aiMsg.text = 'API_KEY未配置请检查.env文件和重启项目'
messages.value = [...messages.value.slice(0, -1), aiMsg]
@ -301,4 +397,20 @@ const handleSend = async () => {
text-align: center; /* 标题居中显示 */
padding: 4px 0; /* 适当增加垂直内边距 */
}
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
vertical-align: middle;
}
.status-dot.online { background: #4caf50; }
.status-dot.idle { background: #9e9e9e; }
.status-dot.busy { background: #f56c6c; }
.status-dot.relax { background: #ffb020; }
.status-dot.soon { background: #409eff; }
</style>

@ -7,6 +7,7 @@ import OCCreationPage from '../components/OCCreationPage.vue'
import HomePage from '../components/HomePage.vue'
import PhonePage from '../components/PhonePage.vue'
import CommunityPage from '../components/CommunityPage.vue'
import OCProfilePage from '../views/OCProfilePage.vue'
// 2. 定义路由规则
const routes = [
@ -14,6 +15,12 @@ const routes = [
path: '/', // 访问路径
redirect: '/login' // 重定向到登录页
},
{
path: '/oc/:id',
name: 'OCProfile',
component: OCProfilePage,
props: true
},
{
path: '/login', // 访问路径http://localhost:5173/login
name: 'Login', // 路由名称

@ -0,0 +1,108 @@
<template>
<div class="oc-profile-page">
<el-card class="oc-card" shadow="hover">
<div class="oc-info-header">
<img class="oc-avatar" :src="oc?.avatar || defaultAvatar" alt="avatar" />
<div class="oc-info-basic">
<div class="oc-name">{{ oc?.name || '未知OC' }}</div>
<div class="oc-meta">
<span>{{ oc?.gender }}</span>
<span v-if="oc?.birthday">{{ oc?.birthday }}</span>
<span v-if="oc?.age">{{ oc?.age }}</span>
</div>
</div>
</div>
<el-divider>基本信息</el-divider>
<div class="oc-info-row"><b>喜好</b>{{ oc?.hobbies || '无' }}</div>
<div class="oc-info-row"><b>关系</b>{{ (oc?.relation && oc.relation.length) ? oc.relation.join('、') : '无' }}</div>
<el-divider>日程表</el-divider>
<div class="oc-info-row schedule-area">
<el-table v-if="scheduleList.length" :data="scheduleList" border size="small" class="oc-schedule-table">
<el-table-column prop="date" label="日期" width="110">
<template #default="scope">
<span>{{ formatDate(scope.row.date) }}</span>
</template>
</el-table-column>
<el-table-column prop="time" label="时间" width="80">
<template #default="scope">
<span>{{ formatTime(scope.row.time) }}</span>
</template>
</el-table-column>
<el-table-column prop="event" label="活动安排">
<template #default="scope">
<span class="event-content">{{ scope.row.event }}</span>
</template>
</el-table-column>
<el-table-column prop="location" label="地点" width="140">
<template #default="scope">
<span>{{ scope.row.location || '-' }}</span>
</template>
</el-table-column>
</el-table>
<div v-else></div>
</div>
<div style="margin-top: 16px; display:flex; gap:8px;">
<el-button type="primary" @click="goEdit"></el-button>
<el-button @click="goBack"></el-button>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getOCById, loadOCFromStorage } from '../stores/ocStore.js'
import { currentUser } from '../stores/userStore.js'
const route = useRoute()
const router = useRouter()
const oc = ref(null)
const scheduleList = ref([])
const defaultAvatar = 'https://i.pravatar.cc/100?img=1'
onMounted(() => {
const id = route.params.id
const username = currentUser?.value?.username || null
if (username) loadOCFromStorage(username)
if (id) {
const numericId = isNaN(Number(id)) ? id : Number(id)
oc.value = getOCById(numericId)
if (oc.value && Array.isArray(oc.value.schedule)) scheduleList.value = [...oc.value.schedule]
}
})
function goBack() {
router.back()
}
function goEdit() {
router.push({ name: 'OCCreationPage', query: { edit: 1, id: oc.value?.id } })
}
function formatDate(date) {
if (!date) return ''
const d = new Date(date)
if (isNaN(d)) return date
return `${d.getFullYear()}${d.getMonth()+1}${d.getDate()}`
}
function formatTime(time) {
if (!time) return ''
if (/^\d{2}:\d{2}$/.test(time)) return time
const t = new Date('1970-01-01T'+time)
if (isNaN(t)) return time
return `${t.getHours().toString().padStart(2,'0')}:${t.getMinutes().toString().padStart(2,'0')}`
}
</script>
<style scoped>
.oc-card { max-width: 760px; margin: 20px auto; }
.oc-info-header { display:flex; gap:16px; align-items:center; }
.oc-avatar { width: 88px; height: 88px; border-radius: 12px; object-fit:cover; }
.oc-name { font-size: 22px; font-weight: 700 }
.oc-meta { color: #909399 }
.schedule-area { background: linear-gradient(90deg, #f7f8fa 60%, #eaf6ff 100%); border-radius: 12px; padding: 12px 16px 8px 16px; min-height: 40px; box-shadow: 0 2px 8px 0 #eaf6ff44; }
.event-content { font-weight: 500; color: #409eff }
</style>
Loading…
Cancel
Save