psnci6hgk 2 months ago
commit 13867065d3

@ -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,129 @@ 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
const currentScheduleEnd = ref(null) // ms timestamp
let pendingReplyTimer = null
let pendingPlaceholderTs = 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 || '忙碌'
}
// store end time for scheduling reply when busy
currentScheduleEnd.value = end.getTime()
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 = ''
currentScheduleEnd.value = null
} else {
statusText.value = '空闲'
statusClass.value = 'idle'
currentActivity.value = ''
currentScheduleEnd.value = null
}
} catch (e) {
statusText.value = '在线'
statusClass.value = 'online'
currentActivity.value = ''
currentScheduleEnd.value = null
}
}
watch(() => props.activeContact, (c) => {
computeStatusFromSchedule(c || {})
if (statusTimer) clearInterval(statusTimer)
statusTimer = setInterval(() => computeStatusFromSchedule(props.activeContact || {}), 60 * 1000)
}, { immediate: true })
// HH:MM YYYY-MM-DD HH:MM
function formatDateTime(ms) {
try {
const d = new Date(ms)
const now = new Date()
const sameDay = d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate()
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
if (sameDay) return `${hh}:${mm}`
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${hh}:${mm}`
} catch (e) { return '' }
}
// activeContact
watch(() => [props.activeContact && props.activeContact.schedule ? props.activeContact.schedule.length : 0, currentScheduleEnd.value], () => {
if (pendingPlaceholderTs && pendingReplyTimer) {
//
if (pendingReplyTimer) clearTimeout(pendingReplyTimer)
let waitMs = 0
if (currentScheduleEnd.value) {
waitMs = Math.max(currentScheduleEnd.value - Date.now(), 0)
} else {
waitMs = 5 * 60 * 1000
}
pendingReplyTimer = setTimeout(() => {
const ph = pendingPlaceholderTs
pendingReplyTimer = null
pendingPlaceholderTs = null
// perform reply for this placeholder
// performAIReply is in scope
try { performAIReply(ph) } catch (e) { /* ignore */ }
}, waitMs)
}
})
onBeforeUnmount(() => {
if (statusTimer) clearInterval(statusTimer)
if (pendingReplyTimer) clearTimeout(pendingReplyTimer)
})
const handleSend = async () => {
if (!canSend.value) return
const userMsg = {
@ -96,8 +222,8 @@ const handleSend = async () => {
const userText = draft.value.trim()
draft.value = ''
// AI
const API_KEY = import.meta.env.VITE_ZHIPU_API_KEY
// prepare placeholder message
const aiMsg = {
senderId: props.activeContact?.id || 'ai',
text: '对方正在输入中...',
@ -106,123 +232,105 @@ const handleSend = async () => {
nextMessages = [...messages.value, aiMsg]
messages.value = nextMessages
emit('update:modelValue', nextMessages)
if (!API_KEY) {
aiMsg.text = 'API_KEY未配置请检查.env文件和重启项目'
messages.value = [...messages.value.slice(0, -1), aiMsg]
emit('update:modelValue', messages.value)
return
}
try {
// prompt OC AI
let persona = ''
if (props.activeContact) {
const c = props.activeContact
const safe = (v) => (v === null || v === undefined) ? '' : String(v).replace(/\s+/g, ' ').trim()
const fmtList = (v) => Array.isArray(v) ? v.join('、') : (v ? safe(v) : '')
persona += `你是${safe(c.name) || '该角色'}`;
// 使
const fields = [
['性别', c.gender || (c.genderOther ? c.genderOther : '')],
['生日', c.birthday],
['年龄', c.age],
['种族', c.race],
['职业', c.occupation],
['发型与发色', c.hair],
['瞳色', c.eyeColor],
['身高', c.height],
['着装风格', c.clothes],
['标志性特征', c.feature],
['核心性格', fmtList(c.personality) || c.personalityOther],
['MBTI', c.mbti],
['优点', c.advantage],
['缺点', c.shortcoming],
['习惯性小动作', c.habit],
['口头禅', c.catchphrase],
['成长环境', c.growup],
['背景故事', c.story],
['秘密', c.secret],
['兴趣爱好', fmtList(c.hobbies)],
['喜欢的食物', c.foodLike],
['讨厌的东西', c.hate],
['理想生活方式', c.life],
['周末日常', c.weekend],
['社区主要活动', c.communityActivity],
['关系期待', fmtList(c.relation) || c.relationOther],
['头像URL', c.avatar]
]
fields.forEach(([label, value]) => {
if (value || value === 0) {
persona += `${label}${safe(value)}`
}
})
const isBusy = statusClass.value === 'busy' || statusText.value.startsWith('正在')
//
if (c.socialCircle && c.socialCircle.length) persona += `社交圈:${c.socialCircle.slice(0,5).map(x => x.name || x).join('、')}`;
if (c.achievements && c.achievements.length) persona += `成就:${c.achievements.slice(0,5).join('、')}`;
// -@
try {
const now = new Date()
const todayStr = now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0') + '-' + String(now.getDate()).padStart(2,'0')
if (Array.isArray(c.schedule)) {
const todays = c.schedule.filter(s => String(s.date).startsWith(todayStr))
if (todays.length) {
const brief = todays.slice(0,6).map(s => `${s.time || '--:--'}-${(s.event||'').slice(0,20)}@${s.location||''}`)
persona += `今日日程(简要):${brief.join('')}`;
// helper that performs the AI call and replaces the placeholder (matched by ts)
const performAIReply = async (placeholderTs) => {
if (!API_KEY) {
const failMsg = 'API_KEY未配置请检查.env文件和重启项目'
// replace placeholder
messages.value = messages.value.map(m => (m.timestamp === placeholderTs ? { ...m, text: failMsg } : m))
emit('update:modelValue', messages.value)
return
}
try {
// build persona
let persona = ''
if (props.activeContact) {
const c = props.activeContact
const safe = (v) => (v === null || v === undefined) ? '' : String(v).replace(/\s+/g, ' ').trim()
const fmtList = (v) => Array.isArray(v) ? v.join('、') : (v ? safe(v) : '')
persona += `你是${safe(c.name) || '该角色'}`;
const fields = [
['性别', c.gender || (c.genderOther ? c.genderOther : '')],
['生日', c.birthday],
['年龄', c.age],
['职业', c.occupation],
['兴趣', fmtList(c.hobbies)]
]
fields.forEach(([label, value]) => {
if (value || value === 0) persona += `${label}${safe(value)}`
})
// include brief todays
try {
const now = new Date()
const todayStr = now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0') + '-' + String(now.getDate()).padStart(2,'0')
if (Array.isArray(c.schedule)) {
const todays = c.schedule.filter(s => String(s.date).startsWith(todayStr))
if (todays.length) {
const brief = todays.slice(0,6).map(s => `${s.time || '--:--'}-${(s.event||'').slice(0,20)}@${s.location||''}`)
persona += `今日日程(简要):${brief.join('')}`;
}
}
}
} catch (e) {
// ignore
} catch (e) {}
}
}
// AI
if (currentUser.value && currentUser.value.nickname) {
persona += `对方昵称:${currentUser.value.nickname}`;
if (currentUser.value.occupation) persona += `对方职业:${currentUser.value.occupation}`;
if (currentUser.value.hobbies) persona += `对方爱好:${currentUser.value.hobbies}`;
}
persona += '请用简洁、贴近生活的语气,只回复一句符合自己人设和性格的聊天内容,如果对方没说就不要刻意强调自己知晓对方的爱好,不要混淆对方和自己的爱好,回复的文本长度根据对方的语句进行调整,比如对方只是简单问候就可以简短回应,对于问题则可以以自身人设风格进行较为详细的解答。';
const prompt = `${persona}\n对方说${userText}`;
const res = await axios.post(
'https://open.bigmodel.cn/api/paas/v4/chat/completions',
{
if (currentUser.value && currentUser.value.nickname) persona += `对方昵称:${currentUser.value.nickname}`;
const prompt = `${persona}\n对方说${userText}`
const res = await axios.post('https://open.bigmodel.cn/api/paas/v4/chat/completions', {
model: 'glm-4-flash',
messages: [{ role: 'user', content: prompt }],
max_tokens: 64,
max_tokens: 128,
temperature: 0.7
},
{
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json'
}
}
)
let reply = res.data.choices?.[0]?.message?.content?.trim()
if (!reply) {
reply = 'AI未能生成回复请稍后重试'
}, { headers: { 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': 'application/json' } })
let reply = res.data.choices?.[0]?.message?.content?.trim() || 'AI未能生成回复请稍后重试'
// replace placeholder message with real reply (match by timestamp)
messages.value = messages.value.map(m => (m.timestamp === placeholderTs ? { ...m, text: reply, timestamp: Date.now() } : m))
emit('update:modelValue', messages.value)
} catch (err) {
let errMsg = 'AI接口调用失败'
if (err?.response?.data?.msg) errMsg += `: ${err.response.data.msg}`
else if (err?.response?.data?.message) errMsg += `: ${err.response.data.message}`
else if (err?.message) errMsg += `: ${err.message}`
messages.value = messages.value.map(m => (m.timestamp === placeholderTs ? { ...m, text: errMsg } : m))
emit('update:modelValue', messages.value)
}
aiMsg.text = reply
aiMsg.timestamp = Date.now()
messages.value = [...messages.value.slice(0, -1), aiMsg]
}
// create placeholder and determine scheduling
const placeholderTs = Date.now()
pendingPlaceholderTs = placeholderTs
// replace last AI placeholder with activity placeholder if busy
if (isBusy) {
const freeAt = currentScheduleEnd.value ? formatDateTime(currentScheduleEnd.value) : ''
const placeholderText = freeAt ? `我正在${currentActivity.value || '忙碌'},请稍等,预计空闲时间为 ${freeAt},等会再来找我玩吧` : `我正在${currentActivity.value || '忙碌'},请稍等`
const placeholder = { senderId: props.activeContact?.id || 'ai', text: placeholderText, timestamp: placeholderTs }
messages.value = [...messages.value.slice(0, -1), placeholder]
emit('update:modelValue', messages.value)
} catch (err) {
let errMsg = 'AI接口调用失败'
if (err?.response?.data?.msg) {
errMsg += `: ${err.response.data.msg}`
} else if (err?.response?.data?.message) {
errMsg += `: ${err.response.data.message}`
} else if (err?.message) {
errMsg += `: ${err.message}`
// compute wait ms until schedule end
let waitMs = 0
if (currentScheduleEnd.value) {
const buf = 0 // no extra buffer; you can set 60000 for 1min buffer
waitMs = Math.max(currentScheduleEnd.value + buf - Date.now(), 0)
} else {
// fallback to 5 minutes
waitMs = 5 * 60 * 1000
}
aiMsg.text = errMsg
messages.value = [...messages.value.slice(0, -1), aiMsg]
// clear previous timer if any
if (pendingReplyTimer) clearTimeout(pendingReplyTimer)
pendingReplyTimer = setTimeout(() => {
pendingReplyTimer = null
pendingPlaceholderTs = null
performAIReply(placeholderTs)
}, waitMs)
// also watch schedule changes: if schedule changes, reschedule by clearing timer and recreating in watcher above
return
} else {
// not busy -> immediate perform and replace
// replace the placeholder we created earlier (the simple 'AI...') with a real-running placeholder timestamp
messages.value = [...messages.value.slice(0, -1), { ...aiMsg, timestamp: placeholderTs }]
emit('update:modelValue', messages.value)
// console
// console.error('AI', err)
await performAIReply(placeholderTs)
}
}
</script>
@ -301,4 +409,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>

@ -14,7 +14,7 @@
<div class="me-name"></div>
<div class="me-sub">在线</div>
</div>
<el-button size="small" type="info" @click="showInfoDialog" style="margin-left:auto;">资料</el-button>
<el-button size="small" type="info" @click="showMyProfile" style="margin-left:auto;">我的资料</el-button>
</div>
<el-tabs v-model="leftTab" class="left-tabs">
<el-tab-pane label="聊天" name="chat" />
@ -53,6 +53,13 @@
<template #footer>
<el-button @click="close"></el-button>
</template>
<!-- OC信息弹窗 -->
<OCInfoDialog
v-model="infoDialogVisible"
:oc="infoDialogOC"
/>
<!-- 我的资料弹窗 -->
<UserCenterDialog v-model="myProfileVisible" />
</el-dialog>
</template>
@ -64,6 +71,7 @@ import { ocList } from '../stores/ocStore'
import { currentUser } from '../stores/userStore'
import OCInfoDialog from './OCInfoDialog.vue'
import UserCenterDialog from './UserCenterDialog.vue'
const props = defineProps({
modelValue: {
@ -105,6 +113,12 @@ const showInfoDialog = () => {
}
}
//
const myProfileVisible = ref(false)
const showMyProfile = () => {
myProfileVisible.value = true
}
const leftTab = ref('chat')
const activeMessages = computed({
@ -156,12 +170,6 @@ watch(activeContactId, id => {
})
</script>
<!-- OC信息弹窗 -->
<OCInfoDialog
v-model="infoDialogVisible"
:oc="infoDialogOC"
/>
<style scoped>
.chat-layout {
display: flex;

@ -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