majiayan_branch
psnci6hgk 2 months ago
parent c9a6c87a8c
commit 9df85d9730

@ -8,11 +8,45 @@
<!-- 社区背景和OC活动区域 -->
<div class="community-scene">
<div class="scene-background">
<img src="/photo/map1.png" alt="社区背景" class="scene-image background-image" />
<img src="/photo/map1.png" alt="社区背景" class="scene-image background-image" />
</div>
<!-- 这里将来会显示OC的位置和活动 -->
<div class="oc-characters">
<!-- OC角色会在这里显示 -->
<!-- 地点标记区域 - 修复移到 oc-characters 外部 -->
<div class="location-pins">
<div
v-for="loc in locationStatuses"
:key="loc.name"
class="location-pin"
:style="{ left: loc.x + '%', top: loc.y + '%' }"
@click.stop="toggleLocation(loc)"
:title="loc.name + ' — ' + (loc.count || 0) + ' 人'"
>
<div class="pin-icon" :class="{ 'empty': loc.count === 0 }"></div>
<div class="pin-label">{{ loc.name }}</div>
<div class="pin-badge" v-if="loc.count">{{ loc.count }}</div>
</div>
</div>
<!-- 地点弹出面板 -->
<div v-if="showLocationPopover && selectedLocationObj" class="location-popover" :style="popoverStyle">
<div class="popover-header">
<strong>{{ selectedLocationObj.name }}</strong>
<span class="popover-count">{{ selectedLocationObj.count }} </span>
</div>
<div class="popover-body">
<div v-if="selectedLocationObj.count === 0" class="no-oc"> OC</div>
<div v-else class="popover-avatars">
<div
v-for="pos in selectedLocationObj.ocs"
:key="pos.id"
class="popover-avatar"
@click.stop="openOCFromPopover(pos.oc)"
:title="pos.oc.name"
>
<img :src="pos.oc.avatar || defaultAvatar" alt="oc" />
</div>
</div>
</div>
</div>
</div>
</div>
@ -40,8 +74,8 @@
<span>OC列表</span>
</el-button>
<el-button
type="info"
<el-button
type="info"
size="large"
@click="showPhone = true"
class="function-btn"
@ -56,20 +90,140 @@
<UserCenterDialog v-model="showUserCenter" />
<OCListDialog v-model="showOCList" />
<PhoneWidget v-model="showPhone" />
<OCInfoDialog v-model="showOCInfo" :oc="selectedOC" />
</div>
</template>
<script setup>
import { ref } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { User, Avatar, Phone } from '@element-plus/icons-vue'
import PhoneWidget from './PhoneWidget.vue'
import UserCenterDialog from './UserCenterDialog.vue'
import OCListDialog from './OCListDialog.vue'
import OCInfoDialog from './OCInfoDialog.vue'
import { ocList } from '../stores/ocStore.js'
//
const defaultAvatar = 'https://i.pravatar.cc/100?img=1'
//
const locationCoords = {
'超市': { x: 15, y: 50 },
'饭店': { x: 40, y: 20 },
'电影院': { x: 80, y: 25 },
'商场': { x: 30, y: 40 },
'公寓': { x: 60, y: 35 },
'甜品店': { x: 25, y: 60 },
'植物园': { x: 15, y: 80 },
'公园': { x: 50, y: 50 },
'体育馆': { x: 80, y: 50 },
'娱乐休闲区': { x: 80, y: 80 },
'宠物店': { x: 35, y: 75 }
}
// OC schedule
const ocPositions = ref([])
// oc
const locationStatuses = ref([])
const computePositions = () => {
const now = new Date()
const todayStr = now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0') + '-' + String(now.getDate()).padStart(2,'0')
const result = []
//
const locMap = {}
Object.keys(locationCoords).forEach(k => {
locMap[k] = { name: k, x: locationCoords[k].x, y: locationCoords[k].y, count: 0, ocs: [] }
})
ocList.value.forEach(oc => {
let loc = ''
if (oc.schedule && oc.schedule.length) {
// <= now
const todays = oc.schedule.filter(s => String(s.date).startsWith(todayStr))
if (todays.length) {
//
todays.sort((a,b) => (a.time||'00:00').localeCompare(b.time||'00:00'))
//
const before = todays.filter(s => (s.time || '00:00') <= now.toTimeString().slice(0,5))
if (before.length) loc = before[before.length-1].location || ''
else loc = todays[0].location || ''
}
}
const coord = locationCoords[loc] || { x: 50, y: 50 }
result.push({ id: oc.id, oc, location: loc, x: coord.x, y: coord.y })
if (loc && locMap[loc]) {
locMap[loc].count += 1
locMap[loc].ocs.push({ id: oc.id, oc, location: loc, x: coord.x, y: coord.y })
}
})
ocPositions.value = result
// locationStatuses
locationStatuses.value = Object.keys(locMap).map(k => locMap[k])
}
let posTimer = null
onMounted(() => {
computePositions()
posTimer = setInterval(computePositions, 60 * 1000)
document.addEventListener('click', closePopoverOnClickOutside)
})
onUnmounted(() => {
if (posTimer) clearInterval(posTimer)
document.removeEventListener('click', closePopoverOnClickOutside)
})
// OC /
const showOCInfo = ref(false)
const selectedOC = ref(null)
//const openOCInfo = (oc) => { selectedOC.value = oc; showOCInfo.value = true }
// /
const showPhone = ref(false)
const showUserCenter = ref(false)
const showOCList = ref(false)
//
const showLocationPopover = ref(false)
const selectedLocationKey = ref('')
const selectedLocationObj = computed(() => {
return locationStatuses.value.find(l => l.name === selectedLocationKey.value) || null
})
const popoverStyle = computed(() => {
if (!selectedLocationObj.value) return { display: 'none' }
return { left: selectedLocationObj.value.x + '%', top: selectedLocationObj.value.y + '%', transform: 'translate(-50%, -120%)' }
})
const toggleLocation = (loc) => {
if (selectedLocationKey.value === loc.name && showLocationPopover.value) {
showLocationPopover.value = false
selectedLocationKey.value = ''
} else {
selectedLocationKey.value = loc.name
showLocationPopover.value = true
}
}
const openOCFromPopover = (ocPos) => {
// OC
selectedOC.value = ocPos.oc
showOCInfo.value = true
//
showLocationPopover.value = false
selectedLocationKey.value = ''
}
//
const closeLocationPopover = () => {
showLocationPopover.value = false
selectedLocationKey.value = ''
}
//
const closePopoverOnClickOutside = (event) => {
if (showLocationPopover.value &&
!event.target.closest('.location-popover') &&
!event.target.closest('.location-pin')) {
closeLocationPopover()
}
}
</script>
<style scoped>
@ -121,15 +275,6 @@ const showOCList = ref(false)
object-fit: cover;
}
.oc-characters {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.right-panel {
width: 200px;
background: rgba(255, 255, 255, 0.1);
@ -168,6 +313,93 @@ const showOCList = ref(false)
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2);
}
/* 地点坐标点 - 修复位置 */
.location-pins {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none; /* children override */
}
.location-pin {
position: absolute;
transform: translate(-50%, -50%);
pointer-events: auto;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
z-index: 20; /* 确保在地图上方 */
}
.pin-icon {
font-size: 18px;
line-height: 18px;
color: #ff5555;
text-shadow: 0 1px 2px rgba(0,0,0,0.4);
}
.pin-icon.empty { color: rgba(255,255,255,0.7); }
.pin-label {
font-size: 12px;
color: white;
text-shadow: 0 1px 2px rgba(0,0,0,0.6);
}
.pin-badge {
margin-top: -6px;
background: rgba(0,0,0,0.6);
color: #fff;
font-size: 11px;
padding: 2px 6px;
border-radius: 12px;
}
/* 地点弹出面板 */
.location-popover {
position: absolute;
z-index: 40;
min-width: 160px;
background: rgba(255,255,255,0.96);
color: #333;
border-radius: 8px;
box-shadow: 0 8px 30px rgba(0,0,0,0.35);
padding: 8px;
transform-origin: bottom center;
}
.location-popover .popover-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.popover-body { max-height: 180px; overflow: auto; }
.popover-avatars { display: flex; gap: 8px; flex-wrap: wrap; }
.popover-avatar {
width: 60px;
/* 删除 height: 44px; 或者改为: */
min-height: 70px;
text-align: center;
cursor: pointer;
margin: 4px;
}
.popover-avatar img {
width: 50px;
height: 50px;
border-radius: 50%;
object-fit: cover;
display: block;
margin: 0 auto;
border: 2px solid #409eff;
}
.no-oc { color: #666; font-size: 13px; }
.avatar-name { /* 新增名字样式 */
font-size: 11px;
margin-top: 4px;
}
.function-btn span {
font-size: 14px;
}

@ -40,6 +40,11 @@
<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-column label="操作" width="90">
<template #default="scope">
<el-button size="small" type="text" @click="editSchedule(scope.$index)"></el-button>
@ -62,6 +67,11 @@
<el-form-item label="时间">
<el-time-picker v-model="scheduleForm.time" value-format="HH:mm" placeholder="选择时间" style="width: 100%" />
</el-form-item>
<el-form-item label="地点">
<el-select v-model="scheduleForm.location" placeholder="选择地点" style="width: 100%">
<el-option v-for="loc in communityLocations" :key="loc" :label="loc" :value="loc" />
</el-select>
</el-form-item>
<el-form-item label="活动">
<el-input v-model="scheduleForm.event" placeholder="活动内容" />
</el-form-item>
@ -82,35 +92,69 @@
<script setup>
import { ref, watch, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { updateOC } from '../stores/ocStore.js'
import { updateOC, loadOCFromStorage, getOCById } from '../stores/ocStore.js'
import { currentUser } from '../stores/userStore.js'
import { useRouter } from 'vue-router'
const props = defineProps({
modelValue: Boolean,
oc: Object
})
//
const communityLocations = [
'超市','饭店','电影院','商场','公寓','甜品店','植物园','公园','体育馆','娱乐休闲区','宠物店'
]
//
const scheduleList = ref([])
const scheduleDialogVisible = ref(false)
const scheduleForm = ref({ date: '', time: '', event: '' })
const scheduleForm = ref({ date: '', time: '', event: '', location: '' })
const editingIndex = ref(-1)
// OC
watch(() => props.oc, (oc) => {
if (oc && Array.isArray(oc.schedule)) {
//
scheduleList.value = [...oc.schedule].sort((a, b) => {
const t1 = new Date(a.date + 'T' + (a.time || '00:00')).getTime()
const t2 = new Date(b.date + 'T' + (b.time || '00:00')).getTime()
return t2 - t1
})
// +
scheduleList.value = [...oc.schedule]
sortSchedule(scheduleList.value)
} else {
scheduleList.value = []
}
}, { immediate: true })
// storage oc oc
watch(() => props.modelValue, (val) => {
if (val) {
//
try {
const username = currentUser?.value?.username || null
if (username) loadOCFromStorage(username)
} catch (e) {
// ignore
}
if (props.oc && props.oc.id) {
const fresh = getOCById(props.oc.id)
if (fresh && Array.isArray(fresh.schedule)) {
scheduleList.value = [...fresh.schedule]
sortSchedule(scheduleList.value)
}
}
}
}, { immediate: false })
// date+time
function sortSchedule(list) {
if (!Array.isArray(list)) return list
list.sort((a, b) => {
const t1 = new Date((a.date || '') + 'T' + (a.time || '00:00')).getTime()
const t2 = new Date((b.date || '') + 'T' + (b.time || '00:00')).getTime()
return (isNaN(t1) ? 0 : t1) - (isNaN(t2) ? 0 : t2)
})
return list
}
function openScheduleDialog() {
scheduleForm.value = { date: '', time: '', event: '' }
scheduleForm.value = { date: '', time: '', event: '', location: communityLocations[0] || '' }
editingIndex.value = -1
scheduleDialogVisible.value = true
}
@ -120,47 +164,148 @@ function editSchedule(idx) {
scheduleDialogVisible.value = true
}
function saveSchedule() {
if (!scheduleForm.value.date || !scheduleForm.value.time || !scheduleForm.value.event) {
if (!scheduleForm.value.date || !scheduleForm.value.time || !scheduleForm.value.event || !scheduleForm.value.location) {
ElMessage.warning('请填写完整信息')
return
}
if (editingIndex.value === -1) {
scheduleList.value.unshift({ ...scheduleForm.value })
scheduleList.value.push({ ...scheduleForm.value })
} else {
scheduleList.value[editingIndex.value] = { ...scheduleForm.value }
}
//
sortSchedule(scheduleList.value)
updateScheduleToOC()
scheduleDialogVisible.value = false
}
function deleteSchedule(idx) {
scheduleList.value.splice(idx, 1)
//
sortSchedule(scheduleList.value)
updateScheduleToOC()
}
function updateScheduleToOC() {
// OC
if (props.oc && props.oc.id) {
//
updateOC(props.oc.id, { schedule: scheduleList.value })
// 使
const username = currentUser?.value?.username || null
updateOC(props.oc.id, { schedule: scheduleList.value }, username)
}
}
function generateScheduleAI() {
// AI
async function generateScheduleAI() {
if (!props.oc) return
const today = new Date()
const dateStr = today.getFullYear() + '-' + String(today.getMonth()+1).padStart(2,'0') + '-' + String(today.getDate()).padStart(2,'0')
const base = []
if (props.oc.occupation) base.push({ time: '09:00', event: `专注于${props.oc.occupation}相关工作` })
if (props.oc.hobbies && props.oc.hobbies.length) {
const hobbies = Array.isArray(props.oc.hobbies) ? props.oc.hobbies : [props.oc.hobbies]
hobbies.slice(0,2).forEach((h, i) => {
base.push({ time: i === 0 ? '14:00' : '16:00', event: `参与${h}活动` })
const API_KEY = import.meta.env.VITE_ZHIPU_API_KEY
try {
//
const today = new Date()
const dateStr = today.getFullYear() + '-' +
String(today.getMonth()+1).padStart(2,'0') + '-' +
String(today.getDate()).padStart(2,'0')
//
const weekDays = ['日', '一', '二', '三', '四', '五', '六']
const weekDay = weekDays[today.getDay()]
// prompt
let persona = `今天是${today.getFullYear()}${today.getMonth()+1}${today.getDate()}日,星期${weekDay}`
persona += `请根据以下OC信息为其生成与现实真实时间一致的今日${dateStr})日程安排,时间段尽量分散,包含工作/爱好/社交/休息等合理内容。`
persona += `返回纯JSON数组不要任何说明或代码块。` + '\n\n'
persona += `OC信息` + '\n'
persona += `- 姓名:${props.oc.name || '未知'}` + '\n'
if (props.oc.occupation) persona += `- 职业:${props.oc.occupation}` + '\n'
if (props.oc.hobbies) persona += `- 爱好:${Array.isArray(props.oc.hobbies) ? props.oc.hobbies.join('、') : props.oc.hobbies}` + '\n'
if (props.oc.personality) persona += `- 性格:${Array.isArray(props.oc.personality) ? props.oc.personality.join('、') : props.oc.personality}` + '\n'
if (props.oc.age) persona += `- 年龄:${props.oc.age}` + '\n'
persona += `\n地点白名单${communityLocations.join('、')}` + '\n\n'
persona += `要求:` + '\n'
persona += `1. 日程必须基于今天的日期:${dateStr}` + '\n'
persona += `2. 时间安排要合理,符合现实生活规律` + '\n'
persona += `3. 活动内容要符合OC的职业、爱好和性格` + '\n'
persona += `4. 每个活动必须包含地点,且只能使用白名单中的地点` + '\n\n'
persona += `输出格式示例:[{"date":"${dateStr}","time":"09:00","event":"示例活动","location":"超市"}, {...}]` + '\n'
persona += `时间格式为HH:MM日期格式为YYYY-MM-DD。返回纯JSON数组。`
// Call AI
let aiResp = null
if (!API_KEY) {
throw new Error('AI API_KEY 未配置')
}
try {
const res = await fetch('https://open.bigmodel.cn/api/paas/v4/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_KEY}`
},
body: JSON.stringify({
model: 'glm-4-flash',
messages: [ { role: 'user', content: persona } ],
max_tokens: 512,
temperature: 0.8
})
})
const data = await res.json()
aiResp = data.choices?.[0]?.message?.content || ''
} catch (e) {
console.error('AI调用失败', e)
}
let parsed = false
let result = []
if (aiResp) {
let str = aiResp.replace(/^```json|```$/g, '').trim()
// JSON
const m = str.match(/\[[\s\S]*\]/)
let candidate = m ? m[0] : str
try {
const parsedJson = JSON.parse(candidate)
if (Array.isArray(parsedJson)) {
result = parsedJson
parsed = true
}
} catch (e) {
console.warn('解析AI输出失败candidate:', candidate)
}
}
if (!parsed) {
// 使
const base = []
if (props.oc.occupation) base.push({ time: '09:00', event: `专注于${props.oc.occupation}相关工作` })
if (props.oc.hobbies && props.oc.hobbies.length) {
const hobbies = Array.isArray(props.oc.hobbies) ? props.oc.hobbies : [props.oc.hobbies]
hobbies.slice(0,2).forEach((h, i) => {
base.push({ time: i === 0 ? '14:00' : '16:00', event: `参与${h}活动` })
})
}
base.push({ time: '18:00', event: '与朋友社交/放松' })
base.push({ time: '20:00', event: '休息与自我提升' })
result = base.map(item => ({ date: dateStr, ...item }))
}
// /
result = result.map(item => {
const loc = item.location && communityLocations.includes(item.location) ? item.location : (communityLocations[0] || '')
return {
date: item.date || dateStr,
time: (item.time || '18:00').slice(0,5),
event: item.event || '',
location: loc
}
})
//
sortSchedule(result)
scheduleList.value = result
updateScheduleToOC()
ElMessage.success('已为该OC生成今日日程')
} catch (err) {
console.error('generateScheduleAI error:', err)
ElMessage.error('生成日程失败:' + (err?.message || '未知错误'))
}
base.push({ time: '18:00', event: '与朋友社交/放松' })
base.push({ time: '20:00', event: '休息与自我提升' })
scheduleList.value = base.map(item => ({ date: dateStr, ...item }))
updateScheduleToOC()
ElMessage.success('已为该OC智能生成今日日程')
}
function formatDate(date) {
if (!date) return ''

@ -46,18 +46,37 @@ const loadOCFromStorage = (username = null) => {
} else {
// 如果没有找到用户特定的OC列表使用默认的
if (username) {
ocList.value = []
// 尝试从通用键迁移(兼容早期版本),如果通用键存在则使用并写入用户特定键
const general = localStorage.getItem('ocList')
if (general) {
try {
const parsed = JSON.parse(general)
ocList.value = parsed
// 同步写入用户专用键以避免下次丢失
localStorage.setItem(storageKey, JSON.stringify(parsed))
} catch (e) {
console.error('Failed to parse general ocList during migration:', e)
ocList.value = []
}
} else {
ocList.value = []
}
}
}
}
// 保存OC列表到本地存储
const saveOCToStorage = (username = null) => {
let storageKey = 'ocList'
if (username) {
storageKey = `ocList_${username}`
const data = JSON.stringify(ocList.value)
// 永远写入通用键,若提供 username 则同时写入用户专属键以保证兼容性
try {
localStorage.setItem('ocList', data)
if (username) {
localStorage.setItem(`ocList_${username}`, data)
}
} catch (e) {
console.error('Failed to save OC list to storage:', e)
}
localStorage.setItem(storageKey, JSON.stringify(ocList.value))
}
// 添加新OC
@ -79,7 +98,8 @@ const addOC = (ocData, username = null) => {
const updateOC = (id, updatedData, username = null) => {
const index = ocList.value.findIndex(oc => oc.id === id)
if (index !== -1) {
ocList.value[index] = { ...ocList.value[index], ...updatedData }
// 就地合并到原对象,保持引用稳定
Object.assign(ocList.value[index], updatedData)
saveOCToStorage(username)
return ocList.value[index]
}

@ -4,7 +4,7 @@
创建oc 填写oc问卷或交给ai随机生成oc上传oc图片或通过填写问卷ai生成图片作为当前oc的头像。
确定oc头像后进入社区界面用户可以自定义oc的日程表或者让ai根据填写的问卷生成日程表游戏内时间需要与用户所处实际时间相同oc会根据自己的日程表安排自己的活动。小屋中包含卧室客厅厨房健身房餐厅超市花店体育馆酒店等等可拓展性活动场所所有oc都在社区里生活工作甚至学习上课社区里也有一些aiNPC和用户的oc一起生活。
进入手机界面用户可以给oc或已结识的NPC发消息进行交流ai会根据oc自身日程表当前的活动方式决策是实时回复还是延时回复比如oc当前正在休闲放松可以实时回复若进行体育运动如游泳就要延时回复NPC同理。
社区地图 暂定包含以下地点超市主卖日常用品饭店电影院商场综合性公寓甜品店植物园公园体育馆娱乐休闲区KTV台球厅电玩城等
社区地图 暂定包含以下地点:超市(主卖日常用品),饭店,电影院,商场(综合性),公寓(个人空间)甜品店植物园公园体育馆娱乐休闲区KTV台球厅电玩城,棋牌室,酒吧等),宠物店。
@ -179,7 +179,13 @@ oc问卷ai随机生成成功实现但创建oc数量好像有最大限制
在聊天界面头像显示过大导致聊天信息显示不完整,需要规范头像大小
0.1.4
调用ai生成日程表根据oc性格和喜好生成一个随机的日程表并显示在oc信息界面中点击日程表可以查看或修改具体日程
将社区地图图片替换成map
将社区地图图片替换成map社区地点上方有坐标点地点名称所在人数根据所有oc的日程表中与现实时间相比较在同一天且相差时间最少的时间点的活动地点与当前地点一致则判断该oc当前在这个地点点击坐标点后显示所在的oc头像点击对应oc头像可查看oc信息
日程表生成后要保存到数据库中下次进入oc信息界面时可以直接显示,日程表在任何情况下显示时要与最近一次生成内容保持同步
0.1.5
点击对应oc头像可查看oc信息
导入地区地图和实时天气,并显示在社区地图界面中
自主构建地图

Loading…
Cancel
Save