Compare commits

...

21 Commits

Author SHA1 Message Date
psnci6hgk f23df340bf 11.14
1 month ago
psnci6hgk 6a09c55252 11.13
1 month ago
psnci6hgk e6066ea334 Merge branch 'linfangfang_branch' of https://bdgit.educoder.net/psnci6hgk/gitProject2
1 month ago
psnci6hgk 1f820bb1c6 修改地图
1 month ago
pb4ustkri 94cc524978 Merge branch 'main' of https://bdgit.educoder.net/psnci6hgk/gitProject2
2 months ago
psnci6hgk 2fad89a507 天气卡片拉取成功
2 months ago
pb4ustkri 118b59e8c6 Merge branch 'main' of https://bdgit.educoder.net/psnci6hgk/gitProject2
2 months ago
pb4ustkri 2b16ccccb4 自动回复
2 months ago
psnci6hgk 9be71e864a Merge branch 'majiayan_branch' of https://bdgit.educoder.net/psnci6hgk/gitProject2
2 months ago
psnci6hgk fef2f38e95 now
2 months ago
psnci6hgk f30c3cf5bc bug修复1
2 months ago
psnci6hgk caca9fb9f6 文档0203更新
2 months ago
psnci6hgk 37dc01141f 合并linfangfang
2 months ago
psnci6hgk 13867065d3 Merge branch 'main' of https://bdgit.educoder.net/psnci6hgk/gitProject2
2 months ago
psnci6hgk 1c45054ab9 Merge branch 'linfangfang_branch' of https://bdgit.educoder.net/psnci6hgk/gitProject2
2 months ago
psnci6hgk f6847c6bda 合并yuajialei:地图显示活动状态,点击头像跳转资料卡片
2 months ago
杨美曦 e288dd93d1 区分手机通信界面我的资料与oc资料弹窗按钮
2 months ago
杨美曦 7ddce25e1d 占位消息优化
2 months ago
杨美曦 4c432aca49 显示oc资料
2 months ago
杨美曦 d095f484ab feat(chat): add OC status bar and delayed AI reply placeholder
2 months ago
pb4ustkri 9778069cf5 修改昵称和oc在地图上的显示
2 months ago

Binary file not shown.

@ -1 +0,0 @@
VITE_ZHIPU_API_KEY=bfde4da145ae42449f0c5bf86d271555.1U5SaZQNaOOPl0kT

@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

@ -1,3 +0,0 @@
{
"recommendations": ["Vue.volar"]
}

@ -1,5 +0,0 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

@ -1,21 +0,0 @@
{
"name": "oc-community-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.12.2",
"element-plus": "^2.11.2",
"vue": "^3.5.18",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"vite": "^7.1.2"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 MiB

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

@ -1,16 +0,0 @@
<template>
<div id="app">
<router-view></router-view>
</div>
</template>
<script setup>
</script>
<style>
#app {
height: 100vh;
margin: 0;
padding: 0;
}
</style>

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

Before

Width:  |  Height:  |  Size: 496 B

@ -1,304 +0,0 @@
<template>
<div class="chat-window">
<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>
<div class="header-actions">
<el-button text :icon="Message" @click="$emit('open-info')"></el-button>
</div>
</div>
<div class="chat-messages" ref="messagesContainer">
<div
v-for="(msg, index) in messages"
:key="index"
class="message-row"
:class="{ mine: msg.senderId === selfId }"
>
<img class="message-avatar" :src="msg.senderId === selfId ? selfAvatar : activeContact?.avatar" alt="avatar" />
<div class="bubble">{{ msg.text }}</div>
</div>
<div v-if="messages.length === 0" class="empty"> {{ activeContact?.name || '...' }} </div>
</div>
<div class="chat-input">
<el-input
v-model="draft"
:placeholder="activeContact ? '输入消息...' : '请选择联系人后再发送'"
:disabled="!activeContact"
@keydown.enter.exact.prevent="handleSend"
clearable
/>
<el-button type="primary" :disabled="!canSend" @click="handleSend"></el-button>
</div>
</div>
</template>
<script setup>
import { onMounted, onUpdated, ref, watch, nextTick, computed } from 'vue'
import { Message, Phone, VideoCamera } from '@element-plus/icons-vue'
import axios from 'axios'
const props = defineProps({
activeContact: {
type: Object,
default: null
},
modelValue: {
type: Array,
default: () => []
},
selfId: {
type: String,
default: 'me'
},
selfAvatar: {
type: String,
default: 'https://avatars.githubusercontent.com/u/9919?v=4'
}
})
const emit = defineEmits(['update:modelValue', 'send'])
const messages = ref(props.modelValue)
watch(() => props.modelValue, v => (messages.value = v))
const draft = ref('')
const messagesContainer = ref(null)
const canSend = computed(() => !!props.activeContact && draft.value.trim().length > 0)
const scrollToBottom = async () => {
await nextTick()
const el = messagesContainer.value
if (el) {
el.scrollTop = el.scrollHeight
}
}
onMounted(scrollToBottom)
onUpdated(scrollToBottom)
watch(messages, scrollToBottom, { deep: true })
import { currentUser } from '../stores/userStore.js'
const handleSend = async () => {
if (!canSend.value) return
const userMsg = {
senderId: props.selfId,
text: draft.value.trim(),
timestamp: Date.now()
}
let nextMessages = [...messages.value, userMsg]
messages.value = nextMessages
emit('update:modelValue', nextMessages)
emit('send', { contact: props.activeContact, message: userMsg })
const userText = draft.value.trim()
draft.value = ''
// AI
const API_KEY = import.meta.env.VITE_ZHIPU_API_KEY
const aiMsg = {
senderId: props.activeContact?.id || 'ai',
text: 'AI思考中...',
timestamp: Date.now() + 1
}
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)}`
}
})
//
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('')}`;
}
}
} catch (e) {
// ignore
}
}
// 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',
{
model: 'glm-4-flash',
messages: [{ role: 'user', content: prompt }],
max_tokens: 64,
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未能生成回复请稍后重试'
}
aiMsg.text = reply
aiMsg.timestamp = Date.now()
messages.value = [...messages.value.slice(0, -1), aiMsg]
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}`
}
aiMsg.text = errMsg
messages.value = [...messages.value.slice(0, -1), aiMsg]
emit('update:modelValue', messages.value)
// console
// console.error('AI', err)
}
}
</script>
<style scoped>
.chat-window {
display: flex;
flex-direction: column;
height: 100%;
background: #f5f7fa;
}
.chat-header {
padding: 10px 12px;
border-bottom: 1px solid #ebeef5;
background: #fff;
}
.chat-title {
font-weight: 600;
}
.chat-subtitle {
font-size: 12px;
color: #909399;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.empty {
margin-top: 24px;
text-align: center;
color: #909399;
}
.message-row {
display: flex;
gap: 8px; /* 减小间距 */
margin-bottom: 16px;
align-items: flex-start;
}
.message-row.mine {
flex-direction: row-reverse;
align-items: flex-start;
}
.message-avatar {
width: 30px; /* 调小尺寸 */
height: 30px; /* 调小尺寸 */
border-radius: 50%;
flex-shrink: 0;
object-fit: cover; /* 防止变形 */
margin-top: 4px; /* 顶部对齐 */
}
.bubble {
max-width: calc(100% - 50px); /* 限制宽度 */
min-height: 30px; /* 最小高度大于头像 */
padding: 8px 10px;
border-radius: 6px;
background: #fff;
border: 1px solid #ebeef5;
box-sizing: border-box; /* 包含padding */
}
.message-row.mine .bubble {
background: #409eff;
color: #fff;
border-color: #409eff;
}
.chat-input {
display: flex;
gap: 8px;
padding: 10px;
border-top: 1px solid #ebeef5;
background: #fff;
}
.chat-title-only {
flex: 1;
overflow: hidden;
text-align: center; /* 标题居中显示 */
padding: 4px 0; /* 适当增加垂直内边距 */
}
</style>

@ -1,726 +0,0 @@
<template>
<div class="community-container">
<!-- 主社区内容区域 -->
<div class="main-content">
<h2>OC社区</h2>
<p>这里是社区场景你的OC正在这里活动</p>
<!-- 社区背景和OC活动区域 -->
<div class="community-scene">
<div class="scene-background">
<div class="background-image-container"></div>
<!-- 添加天气信息显示 -->
<div v-if="weatherInfo" class="weather-widget">
<div class="weather-content">
<div class="weather-details">
<!-- 添加地址输入框 -->
<div class="location-input">
<el-input
v-model="inputLocation"
placeholder="输入任意城市名称"
size="small"
@keyup.enter="updateWeather"
:disabled="weatherLoading"
>
<template #append>
<el-button
@click="updateWeather"
:icon="Search"
:loading="weatherLoading"
/>
</template>
</el-input>
</div>
<div class="temperature">{{ weatherInfo.temperature }}°C</div>
<div class="condition">{{ weatherInfo.condition }}</div>
<div class="location">{{ weatherInfo.province }}{{ weatherInfo.city !== weatherInfo.province ? weatherInfo.city : '' }}</div>
<div class="weather-extras" v-if="weatherInfo?.humidity">
<span>湿度: {{ weatherInfo.humidity }}%</span>
<span>风速: {{ weatherInfo.windSpeed }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 地点标记区域 - 修复移到 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>
<!-- 右侧功能按钮区域 -->
<div class="right-panel">
<div class="function-buttons">
<el-button
type="primary"
size="large"
@click="showUserCenter = true"
class="function-btn"
>
<el-icon><User /></el-icon>
<span>用户中心</span>
</el-button>
<el-button
type="success"
size="large"
@click="showOCList = true"
class="function-btn"
>
<el-icon><Avatar /></el-icon>
<span>OC列表</span>
</el-button>
<el-button
type="info"
size="large"
@click="showPhone = true"
class="function-btn"
>
<el-icon><Phone /></el-icon>
<span>手机通信</span>
</el-button>
</div>
</div>
<!-- 弹窗组件 -->
<UserCenterDialog v-model="showUserCenter" />
<OCListDialog v-model="showOCList" />
<PhoneWidget v-model="showPhone" />
<OCInfoDialog v-model="showOCInfo" :oc="selectedOC" />
</div>
</template>
<script setup>
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 inputLocation = ref('天津')
const currentLocation = ref('天津')
//
const getRealMap = async (location) => {
return {
imageUrl: '/photo/map1.png',
coordinates:
locationCoords
}
}
//
const updateWeather = async () => {
if (inputLocation.value.trim()) {
weatherLoading.value = true
currentLocation.value = inputLocation.value.trim()
try {
const weatherData = await getWeatherData(currentLocation.value)
weatherInfo.value = weatherData
} catch (error) {
console.error('更新天气失败:', error)
} finally {
weatherLoading.value = false
}
}
}
const getWeatherData = async (location) => {
try {
const API_KEY = '7d1e2dfdd86a45c64e4178c0ad321fcd'
// API
const geoResponse = await fetch(`https://restapi.amap.com/v3/geocode/geo?address=${encodeURIComponent(location)}&key=${API_KEY}`)
const geoData = await geoResponse.json()
if (geoData.status !== '1' || !geoData.geocodes || geoData.geocodes.length === 0) {
throw new Error(`找不到地点: ${location}`)
}
//
const adcode = geoData.geocodes[0].adcode
// 使
const weatherResponse = await fetch(`https://restapi.amap.com/v3/weather/weatherInfo?city=${adcode}&key=${API_KEY}&extensions=base`)
const weatherData = await weatherResponse.json()
if (weatherData.status === '1' && weatherData.lives && weatherData.lives.length > 0) {
const live = weatherData.lives[0]
return {
temperature: live.temperature,
condition: live.weather,
humidity: live.humidity,
windSpeed: live.windpower,
windDir: live.winddirection,
reportTime: live.reporttime,
province: live.province,
city: live.city
}
} else {
throw new Error('获取天气数据失败')
}
} catch (error) {
console.error('获取真实天气失败:', error)
return getMockWeatherData(location)
}
}
//
const getMockWeatherData = (location) => {
//
const conditions = ['晴', '多云', '阴', '小雨', '中雨', '大雨', '阵雨', '雷阵雨', '雪', '雾', '霾']
const randomCondition = conditions[Math.floor(Math.random() * conditions.length)]
//
const now = new Date()
const month = now.getMonth() + 1
let baseTemp = 20
if (month >= 3 && month <= 5) baseTemp = 18 //
else if (month >= 6 && month <= 8) baseTemp = 28 //
else if (month >= 9 && month <= 11) baseTemp = 15 //
else baseTemp = 5 //
const temperature = baseTemp + Math.floor(Math.random() * 15) - 5
return {
temperature: temperature,
condition: randomCondition,
humidity: 30 + Math.floor(Math.random() * 50),
windSpeed: (1 + Math.random() * 6).toFixed(1),
province: '未知省份',
city: location
}
}
//
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 }
}
const currentMap = ref('')
const weatherInfo = ref(null)
const weatherLoading = ref(false)
// 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)
initMapAndWeather()
})
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()
}
}
const initMapAndWeather = async () => {
try {
// 使
const mapData = await getRealMap(currentLocation.value)
currentMap
.value = mapData.
imageUrl
//
const weatherData = await getWeatherData(currentLocation.value)
weatherInfo
.value =
weatherData
} catch (error) {
console
.error('初始化天气失败:', error)
currentMap
.value = '/photo/map1.png'
}
}
</script>
<style scoped>
.community-container {
display: flex;
height: 100vh;
background: linear-gradient(135deg, #ffc6c6 0%, #7486c0 100%);
position: relative;
overflow: hidden;
}
.main-content {
flex: 1;
padding: 20px;
color: white;
display: flex;
flex-direction: column;
}
.main-content h2 {
margin: 0 0 10px 0;
font-size: 28px;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
}
.main-content p {
margin: 0 0 20px 0;
font-size: 16px;
opacity: 0.9;
}
.community-scene {
flex: 1;
position: relative;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
background: #f0f0f0;
/* 强制基于视口计算高度不受父元素padding影响 */
}
.scene-background {
width: 100%;
height: 100%;
position: relative;
background: #f0f0f0; /* 加载前的背景色 */
overflow: visible; /* 改为visible避免裁剪 */
}
.background-image-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
/* 使用背景图替代img利用background-size控制显示方式 */
background-image: url("/photo/map1.png");
background-repeat: no-repeat;
background-position: center; /* 图片居中显示 */
background-size: contain; /* 完整显示图片,可能有留白 */
/*
background-size: cover;
*/
z-index: 1; /* 确保在更底层,不遮挡其他内容 */
pointer-events: none; /* 禁止背景图片拦截鼠标事件,保障天气卡等可交互 */
}
.right-panel {
width: 200px;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border-left: 1px solid rgba(255, 255, 255, 0.2);
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 10px;
}
.function-buttons {
display: flex;
flex-direction: column;
gap: 15px;
width: 100%;
align-items: center;
}
.function-btn {
width: 100%;
height: 60px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 5px;
border-radius: 12px;
font-weight: 600;
transition: all 0.3s ease;
border: 2px solid transparent;
}
.function-btn:hover {
transform: translateY(-2px);
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;
z-index: 10; /* 高于背景图 */
}
.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;
}
.function-btn .el-icon {
font-size: 20px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.community-container {
flex-direction: column;
}
.right-panel {
width: 100%;
height: auto;
flex-direction: row;
justify-content: space-around;
padding: 10px;
}
.function-buttons {
flex-direction: row;
gap: 10px;
}
.function-btn {
height: 50px;
flex: 1;
}
.function-btn span {
font-size: 12px;
}
}
/* 响应式调整 */
@media (max-width: 768px) {
.weather-widget {
top: 10px;
right: 10px;
padding: 8px;
min-width: 110px;
max-width: 130px;
}
.temperature {
font-size: 14px;
}
.condition, .location {
font-size: 10px;
}
.weather-extras {
font-size: 8px;
}
}
@media (max-width: 768px) {
.community-scene {
height: calc(100vh - 150px); /* 小屏幕下根据布局调整高度,适配更多空间 */
}
.scene-image {
object-fit: contain; /* 小屏幕下优先保证图片完整显示 */
}
}
/* 天气组件样式 */
.weather-widget {
position: absolute;
top: 20px;
right: 20px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 12px;
padding: 10px; /* 减少内边距 */
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
z-index: 30;
min-width: 120px; /* 减小最小宽度 */
max-width: 140px; /* 添加最大宽度限制 */
}
.weather-content {
display: flex;
flex-direction: column;
gap: 6px; /* 减小间距 */
}
.weather-details {
color: #333;
text-align: center;
font-size: 0.9em; /* 整体缩小字体 */
}
.location-input {
margin-bottom: 6px;
}
.location-input :deep(.el-input-group) {
background: rgba(255,255,255,0.9);
border-radius: 4px;
border: 1px solid #e4e7ed;
height: 28px; /* 减小高度 */
}
.location-input :deep(.el-input__inner) {
border: none;
background: transparent;
padding: 0 8px;
font-size: 11px; /* 减小字体 */
height: 26px;
line-height: 26px;
}
.location-input :deep(.el-input-group__append) {
padding: 0 6px;
background: transparent;
}
.location-input :deep(.el-button) {
padding: 6px;
height: auto;
}
.temperature {
font-size: 16px; /* 减小温度字体 */
font-weight: bold;
margin-bottom: 2px;
}
.condition {
font-size: 11px; /* 减小天气状况字体 */
opacity: 0.8;
margin-bottom: 2px;
}
.location {
font-size: 10px; /* 减小地点字体 */
opacity: 0.6;
margin-bottom: 4px;
}
.weather-extras {
font-size: 9px; /* 减小额外信息字体 */
opacity: 0.7;
margin-top: 4px;
}
.weather-extras span {
display: block;
line-height: 1.2;
}
</style>

@ -1,128 +0,0 @@
<template>
<div class="contacts">
<div class="contacts-header">
<el-input v-model="keyword" placeholder="搜索联系人" clearable />
</div>
<el-scrollbar class="contacts-scroll">
<div
v-for="c in filtered"
:key="c.id"
class="contact-item"
:class="{ active: c.id === modelValue }"
@click="select(c.id)"
>
<img class="avatar" :src="c.avatar" alt="avatar" />
<div class="meta">
<div class="name">{{ c.name }}</div>
<div v-if="!simpleMode" class="last">{{ c.lastMessage || '...' }}</div>
</div>
<div v-if="!simpleMode && c.lastTime" class="time">{{ formatTime(c.lastTime) }}</div>
<el-badge v-if="!simpleMode && c.unread" :value="c.unread" class="unread" />
</div>
<div v-if="filtered.length === 0" class="empty"></div>
</el-scrollbar>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
const props = defineProps({
modelValue: {
type: String,
default: ''
},
contacts: {
type: Array,
default: () => []
},
simpleMode: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue'])
const keyword = ref('')
const filtered = computed(() => {
const k = keyword.value.trim().toLowerCase()
if (!k) return props.contacts
return props.contacts.filter(c =>
c.name.toLowerCase().includes(k)
)
})
const select = id => emit('update:modelValue', id)
const formatTime = ts => {
try {
const d = new Date(ts)
const h = String(d.getHours()).padStart(2, '0')
const m = String(d.getMinutes()).padStart(2, '0')
return `${h}:${m}`
} catch (e) {
return ''
}
}
</script>
<style scoped>
.contacts {
display: flex;
flex-direction: column;
height: 100%;
border-right: 1px solid #ebeef5;
background: #fff;
}
.contacts-header {
padding: 10px;
border-bottom: 1px solid #ebeef5;
}
.contacts-scroll {
height: 100%;
}
.contact-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
cursor: pointer;
}
.contact-item:hover {
background: #f5f7fa;
}
.contact-item.active {
background: #ecf5ff;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 8px;
}
.meta {
flex: 1;
min-width: 0;
}
.name {
font-weight: 600;
}
.last {
font-size: 12px;
color: #909399;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.time {
font-size: 12px;
color: #909399;
}
.empty {
padding: 16px;
text-align: center;
color: #909399;
}
</style>

@ -1,37 +0,0 @@
<template>
<div class="home-container">
<Navigation />
<div class="content">
<h2>首页</h2>
<el-space>
<el-button type="primary" @click="goCreate">OC</el-button>
<el-button @click="goCommunity"></el-button>
<el-button type="success" @click="goPhone"></el-button>
</el-space>
</div>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router'
import Navigation from './Navigation.vue'
const router = useRouter()
const goCreate = () => router.push('/create-oc')
const goCommunity = () => router.push('/community')
const goPhone = () => router.push('/phone')
</script>
<style scoped>
.home-container {
display: flex;
flex-direction: column;
gap: 16px;
}
.content {
padding: 16px;
}
</style>

@ -1,237 +0,0 @@
<template>
<div class="login-container">
<div class="login-card">
<div class="login-header">
<h2>{{ isLoginMode ? '登录' : '注册' }}</h2>
<p>{{ isLoginMode ? '欢迎回来!' : '创建新账户' }}</p>
</div>
<el-form :model="form" label-width="80px" class="login-form">
<el-form-item label="用户名">
<el-input v-model="form.username" placeholder="请输入用户名" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="form.password" type="password" placeholder="请输入密码" />
</el-form-item>
<!-- 注册时才显示的字段 -->
<template v-if="!isLoginMode">
<el-form-item label="昵称">
<el-input v-model="form.nickname" placeholder="请输入昵称" />
</el-form-item>
<el-form-item label="生日">
<el-date-picker
v-model="form.birthday"
type="date"
placeholder="选择生日"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
/>
</el-form-item>
<el-form-item label="职业">
<el-input v-model="form.occupation" placeholder="请输入职业" />
</el-form-item>
<el-form-item label="喜好">
<el-input v-model="form.hobbies" placeholder="请输入你的喜好" />
</el-form-item>
<el-form-item label="个人简介">
<el-input
v-model="form.bio"
type="textarea"
:rows="3"
placeholder="介绍一下自己吧"
/>
</el-form-item>
</template>
<el-form-item>
<el-button
type="primary"
@click="isLoginMode ? handleLogin() : handleRegister()"
:loading="loading"
class="submit-btn"
>
{{ isLoginMode ? '登录' : '注册' }}
</el-button>
</el-form-item>
<el-form-item>
<el-button type="text" @click="toggleMode" class="toggle-btn">
{{ isLoginMode ? '还没有账户?点击注册' : '已有账户?点击登录' }}
</el-button>
</el-form-item>
</el-form>
</div>
</div>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElLoading } from 'element-plus'
import { login, register } from '../stores/userStore.js'
const router = useRouter()
const form = reactive({
username: '',
password: '',
nickname: '',
birthday: '',
occupation: '',
hobbies: '',
bio: ''
})
const isLoginMode = ref(true)
const loading = ref(false)
const handleLogin = async () => {
if (!form.username || !form.password) {
ElMessage.warning('请输入用户名和密码')
return
}
loading.value = true
try {
const result = await login({
username: form.username,
password: form.password
})
if (result.success) {
ElMessage.success('登录成功')
router.push('/community')
}
} catch (error) {
ElMessage.error(error.message || '登录失败')
} finally {
loading.value = false
}
}
const handleRegister = async () => {
if (!form.username || !form.password) {
ElMessage.warning('请输入用户名和密码')
return
}
loading.value = true
try {
const result = await register({
username: form.username,
password: form.password,
nickname: form.nickname,
birthday: form.birthday,
occupation: form.occupation,
hobbies: form.hobbies,
bio: form.bio
})
if (result.success) {
ElMessage.success('注册成功,已自动登录')
//
router.push('/community')
}
} catch (error) {
ElMessage.error(error.message || '注册失败')
} finally {
loading.value = false
}
}
const toggleMode = () => {
isLoginMode.value = !isLoginMode.value
//
Object.keys(form).forEach(key => {
form[key] = ''
})
}
</script>
<style scoped>
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
}
.login-card {
background: white;
border-radius: 16px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
padding: 40px;
width: 100%;
max-width: 500px;
animation: slideUp 0.6s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.login-header {
text-align: center;
margin-bottom: 30px;
}
.login-header h2 {
margin: 0 0 10px 0;
color: #303133;
font-size: 28px;
font-weight: 600;
}
.login-header p {
margin: 0;
color: #909399;
font-size: 16px;
}
.login-form {
margin-top: 20px;
}
.submit-btn {
width: 100%;
height: 45px;
font-size: 16px;
font-weight: 600;
border-radius: 8px;
}
.toggle-btn {
width: 100%;
color: #409eff;
font-size: 14px;
}
.toggle-btn:hover {
color: #66b1ff;
}
/* 响应式设计 */
@media (max-width: 768px) {
.login-card {
padding: 30px 20px;
margin: 10px;
}
.login-header h2 {
font-size: 24px;
}
.login-header p {
font-size: 14px;
}
}
</style>

@ -1,13 +0,0 @@
<template>
<div class="navigation">
<el-menu mode="horizontal" :router="true">
<el-menu-item index="/login">登录</el-menu-item>
<el-menu-item index="/create-oc">创建OC</el-menu-item>
<el-menu-item index="/community">社区</el-menu-item>
</el-menu>
</div>
</template>
<script setup>
// 使 :router="true"
</script>

@ -1,420 +0,0 @@
<template>
<div class="creation-container">
<div class="creation-card">
<div class="creation-header">
<h2>OC问卷</h2>
<p>完善角色信息让TA在社区中生活吧</p>
</div>
<el-form :model="ocForm" label-width="120px" class="creation-form">
<!-- 1. 基础信息 -->
<el-divider>基础信息</el-divider>
<el-form-item label="角色名称" required>
<el-input v-model="ocForm.name" placeholder="请输入OC名称" />
</el-form-item>
<el-form-item label="性别">
<el-radio-group v-model="ocForm.gender">
<el-radio label="男"></el-radio>
<el-radio label="女"></el-radio>
<el-radio label="其他">其他</el-radio>
</el-radio-group>
<el-input v-if="ocForm.gender===''" v-model="ocForm.genderOther" placeholder="自定义性别" style="width:120px;margin-left:8px;" />
</el-form-item>
<el-form-item label="生日">
<el-input v-model="ocForm.birthday" placeholder="如08-15 (月-日)" maxlength="5" />
</el-form-item>
<el-form-item label="年龄">
<el-input v-model="ocForm.age" placeholder="请输入年龄" />
</el-form-item>
<el-form-item label="种族">
<el-input v-model="ocForm.race" placeholder="如:人类、精灵、机器人等" />
</el-form-item>
<el-form-item label="职业">
<el-input v-model="ocForm.occupation" placeholder="请输入职业" />
</el-form-item>
<!-- 2. 外表形象 -->
<el-divider>外表形象</el-divider>
<el-form-item label="发型与发色">
<el-input v-model="ocForm.hair" placeholder="如:银色短发" />
</el-form-item>
<el-form-item label="瞳色">
<el-input v-model="ocForm.eyeColor" placeholder="如:琥珀色" />
</el-form-item>
<el-form-item label="大致身高">
<el-input v-model="ocForm.height" placeholder="如170cm" />
</el-form-item>
<el-form-item label="着装风格">
<el-input v-model="ocForm.clothes" placeholder="如:休闲运动、哥特礼服等" />
</el-form-item>
<el-form-item label="标志性特征">
<el-input v-model="ocForm.feature" placeholder="如:猫耳、机械臂等" />
</el-form-item>
<!-- 3. 性格与内心 -->
<el-divider>性格与内心</el-divider>
<el-form-item label="核心性格">
<el-checkbox-group v-model="ocForm.personality">
<el-checkbox label="开朗外向"/><el-checkbox label="安静内向"/><el-checkbox label="成熟稳重"/><el-checkbox label="天真烂漫"/><el-checkbox label="理性冷静"/><el-checkbox label="感性冲动"/><el-checkbox label="温柔体贴"/><el-checkbox label="傲娇冷淡"/><el-checkbox label="幽默搞怪"/><el-checkbox label="认真固执"/><el-checkbox label="悲观消极"/><el-checkbox label="乐观积极"/><el-checkbox label="腹黑"/><el-checkbox label="忠犬"/><el-checkbox label="其他"/>
</el-checkbox-group>
<el-input v-if="ocForm.personality?.includes('')" v-model="ocForm.personalityOther" placeholder="自定义性格" style="width:120px;margin-left:8px;" />
</el-form-item>
<el-form-item label="MBTI人格类型">
<el-input v-model="ocForm.mbti" placeholder="如INFP" />
</el-form-item>
<el-form-item label="最大优点">
<el-input v-model="ocForm.advantage" />
</el-form-item>
<el-form-item label="最大缺点">
<el-input v-model="ocForm.shortcoming" />
</el-form-item>
<el-form-item label="习惯性小动作">
<el-input v-model="ocForm.habit" />
</el-form-item>
<el-form-item label="口头禅">
<el-input v-model="ocForm.catchphrase" />
</el-form-item>
<!-- 4. 背景故事与关系 -->
<el-divider>背景故事与关系</el-divider>
<el-form-item label="成长环境">
<el-input v-model="ocForm.growup" placeholder="如:都市、乡村等" />
</el-form-item>
<el-form-item label="背景故事">
<el-input v-model="ocForm.story" type="textarea" :rows="2" />
</el-form-item>
<el-form-item label="想要隐藏的秘密">
<el-input v-model="ocForm.secret" />
</el-form-item>
<!-- 5. 喜好与日常 -->
<el-divider>喜好与日常</el-divider>
<el-form-item label="兴趣爱好">
<el-input v-model="ocForm.hobbies" placeholder="如:阅读、健身等" />
</el-form-item>
<el-form-item label="喜欢的食物">
<el-input v-model="ocForm.foodLike" />
</el-form-item>
<el-form-item label="讨厌的东西">
<el-input v-model="ocForm.hate" />
</el-form-item>
<el-form-item label="理想生活方式">
<el-input v-model="ocForm.life" />
</el-form-item>
<el-form-item label="周末日常">
<el-input v-model="ocForm.weekend" />
</el-form-item>
<!-- 6. 社区角色期待 -->
<el-divider>社区角色期待</el-divider>
<el-form-item label="社区主要活动">
<el-input v-model="ocForm.communityActivity" placeholder="如:在花店打工" />
</el-form-item>
<el-form-item label="关系期待">
<el-checkbox-group v-model="ocForm.relation">
<el-checkbox label="亲密无间的朋友"/><el-checkbox label="互相扶持的伙伴"/><el-checkbox label="需要照顾的后辈"/><el-checkbox label="值得信赖的前辈"/><el-checkbox label="略带距离感的熟人"/><el-checkbox label="其他"/>
</el-checkbox-group>
<el-input v-if="ocForm.relation?.includes('')" v-model="ocForm.relationOther" placeholder="自定义关系" style="width:120px;margin-left:8px;" />
</el-form-item>
<!-- 头像 -->
<el-divider>头像</el-divider>
<el-form-item label="头像URL">
<el-input v-model="ocForm.avatar" placeholder="头像URL可选" />
</el-form-item>
<el-form-item>
<el-button
type="primary"
@click="handleAIGenerate"
class="action-btn"
>
AI随机生成
</el-button>
<el-button
type="success"
@click="handleSubmit"
:loading="loading"
class="action-btn"
>
保存
</el-button>
<el-button
type="info"
@click="handleCancel"
class="action-btn"
>
取消
</el-button>
</el-form-item>
</el-form>
</div>
</div>
</template>
<script setup>
import { reactive, ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { addOC, updateOC, getOCById } from '../stores/ocStore.js'
import { currentUser } from '../stores/userStore.js'
import axios from 'axios'
const router = useRouter()
const route = useRoute()
const loading = ref(false)
const ocForm = reactive({
name: '', gender: '', genderOther: '', birthday: '', age: '', race: '', occupation: '',
hair: '', eyeColor: '', height: '', clothes: '', feature: '',
personality: [], personalityOther: '', mbti: '', advantage: '', shortcoming: '', habit: '', catchphrase: '',
growup: '', story: '', secret: '',
hobbies: '', foodLike: '', hate: '', life: '', weekend: '',
communityActivity: '', relation: [], relationOther: '',
avatar: ''
})
const isEdit = ref(false)
const editId = ref(null)
// id
onMounted(() => {
if (route.query.edit && route.query.id) {
isEdit.value = true
editId.value = Number(route.query.id)
const oc = getOCById(editId.value)
if (oc) {
Object.keys(ocForm).forEach(k => {
if (oc[k] !== undefined) {
if (Array.isArray(ocForm[k]) && typeof oc[k] === 'string') {
ocForm[k] = oc[k].split(/[,]/).map(s => s.trim()).filter(Boolean)
} else if (typeof ocForm[k] === 'string' && typeof oc[k] !== 'string') {
ocForm[k] = String(oc[k])
} else if (Array.isArray(ocForm[k]) && Array.isArray(oc[k])) {
ocForm[k] = oc[k].map(x => typeof x === 'string' ? x.trim() : String(x))
} else {
ocForm[k] = oc[k]
}
}
})
}
}
})
const handleAIGenerate = async () => {
ElMessage.info('AI生成中...')
const API_KEY = import.meta.env.VITE_ZHIPU_API_KEY
const prompt = `生成一个OC角色的JSON对象字段有
name, gender, genderOther, birthday, age, race, occupation, hair, eyeColor, height, clothes, feature, personality(数组), personalityOther, mbti, advantage, shortcoming, habit, catchphrase, growup, story, secret, hobbies(数组), foodLike, hate, life, weekend, communityActivity, relationOther
每个字段都要有合理的虚构内容生成人设可以为现实魔幻二次元等风格随机性强不要总生成相似的人设必须为中文其中birthday只有月日如8-15不能留空不能为[]不能为null
只输出JSON对象本身不要任何推理解释reasoning注释代码块markdown标记或多余内容`;
try {
const res = await axios.post(
'https://open.bigmodel.cn/api/paas/v4/chat/completions',
{
model: 'glm-4-flash',
messages: [
{ role: 'user', content: prompt }
],
temperature: 0.9,
max_tokens: 1024,
thinking: { type: 'enabled' }
},
{
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json'
}
}
)
console.log('AI接口完整响应:', res.data)
let jsonStr = res.data.choices?.[0]?.message?.content || ''
// AImarkdown
jsonStr = jsonStr.replace(/^```json|```$/g, '').trim()
// JSON
let data = {}
let parsed = false
try {
data = JSON.parse(jsonStr)
parsed = true
} catch {}
if (!parsed) {
// {...}
const match = jsonStr.match(/\{[\s\S]*\}/)
if (match) {
try {
data = JSON.parse(match[0])
parsed = true
} catch {}
}
}
// Bcontentreasoning_contentreasoning_contentJSON
if (!parsed && res.data.choices?.[0]?.message?.reasoning_content) {
const reasoning = res.data.choices[0].message.reasoning_content
const match = reasoning.match(/\{[\s\S]*\}/)
if (match) {
try {
data = JSON.parse(match[0])
parsed = true
} catch {}
}
}
if (!parsed) {
ElMessage.error('AI生成内容解析失败原始内容已打印到控制台')
console.error('AI原始内容', jsonStr)
if (res.data.choices?.[0]?.message?.reasoning_content) {
console.error('AI reasoning_content', res.data.choices[0].message.reasoning_content)
}
return
}
// AIpersonality/relation
Object.keys(ocForm).forEach(k => {
if (data[k] !== undefined) {
if (Array.isArray(ocForm[k]) && typeof data[k] === 'string') {
ocForm[k] = data[k].split(/[,]/).map(s => s.trim()).filter(Boolean)
} else if (typeof ocForm[k] === 'string' && typeof data[k] !== 'string') {
ocForm[k] = String(data[k])
} else if (Array.isArray(ocForm[k]) && Array.isArray(data[k])) {
ocForm[k] = data[k].map(x => typeof x === 'string' ? x.trim() : String(x))
} else {
ocForm[k] = data[k]
}
}
})
console.log('AI同步到表单后的ocForm:', JSON.parse(JSON.stringify(ocForm)))
ElMessage.success('AI生成完成')
} catch (err) {
let msg = 'AI生成失败'
if (err?.response?.data?.msg) msg += ': ' + err.response.data.msg
else if (err?.message) msg += ': ' + err.message
ElMessage.error(msg)
}
}
const handleSubmit = async () => {
if (!ocForm.name.trim()) {
ElMessage.warning('请输入OC名称')
return
}
loading.value = true
try {
//
const hobbies = ocForm.hobbies ? ocForm.hobbies.split(/[,]/).map(h => h.trim()).filter(h => h) : []
const relation = Array.isArray(ocForm.relation) ? ocForm.relation : (ocForm.relation ? ocForm.relation.split(/[,]/).map(r => r.trim()).filter(r => r) : [])
// OC
const ocData = {
...ocForm,
hobbies,
relation
}
if (isEdit.value && editId.value) {
// OC
updateOC(editId.value, ocData, currentUser.value.username)
ElMessage.success('OC信息已保存')
} else {
//
addOC(ocData, currentUser.value.username)
ElMessage.success('OC创建成功')
}
router.push('/community')
} catch (error) {
ElMessage.error((isEdit.value ? '保存失败:' : '创建失败:') + error.message)
} finally {
loading.value = false
}
}
const handleCancel = () => {
//
Object.keys(ocForm).forEach(key => {
ocForm[key] = ''
})
//
router.push('/community')
}
</script>
<style scoped>
.creation-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #9ae1ff 0%, #ffbefa 100%);
padding: 20px;
}
.creation-card {
background: white;
border-radius: 16px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
padding: 40px;
width: 100%;
max-width: 600px;
animation: slideUp 0.6s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.creation-header {
text-align: center;
margin-bottom: 30px;
}
.creation-header h2 {
margin: 0 0 10px 0;
color: #303133;
font-size: 28px;
font-weight: 600;
}
.creation-header p {
margin: 0;
color: #909399;
font-size: 16px;
}
.creation-form {
margin-top: 20px;
}
.action-btn {
width: 120px;
height: 40px;
font-weight: 600;
border-radius: 8px;
margin-right: 10px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.creation-card {
padding: 30px 20px;
margin: 10px;
}
.creation-header h2 {
font-size: 24px;
}
.creation-header p {
font-size: 14px;
}
.action-btn {
width: 100%;
margin: 5px 0;
}
}
</style>

@ -1,408 +0,0 @@
<template>
<el-dialog v-model="visible" :title="oc?.name + ' 的信息'" width="420px" @close="onClose" append-to-body>
<div class="oc-info-main">
<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 }}</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"
:row-class-name="scheduleRowClass"
>
<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-column label="操作" width="90">
<template #default="scope">
<el-button size="small" type="text" @click="editSchedule(scope.$index)"></el-button>
<el-button size="small" type="text" @click="deleteSchedule(scope.$index)"></el-button>
</template>
</el-table-column>
</el-table>
<div v-else></div>
<div class="schedule-actions">
<el-button size="small" type="primary" @click="openScheduleDialog"></el-button>
<el-button size="small" type="success" @click="generateScheduleAI">AI</el-button>
</div>
</div>
<!-- 日程编辑弹窗 -->
<el-dialog v-model="scheduleDialogVisible" title="编辑日程" width="340px" append-to-body>
<el-form :model="scheduleForm" label-width="60px">
<el-form-item label="日期">
<el-date-picker v-model="scheduleForm.date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width: 100%" />
</el-form-item>
<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>
</el-form>
<template #footer>
<el-button @click="scheduleDialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveSchedule"></el-button>
</template>
</el-dialog>
</div>
<template #footer>
<el-button type="primary" @click="handleEdit"></el-button>
<el-button @click="onClose"></el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch, computed } from 'vue'
import { ElMessage } from 'element-plus'
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: '', location: '' })
const editingIndex = ref(-1)
// OC
watch(() => props.oc, (oc) => {
if (oc && Array.isArray(oc.schedule)) {
// +
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: '', location: communityLocations[0] || '' }
editingIndex.value = -1
scheduleDialogVisible.value = true
}
function editSchedule(idx) {
scheduleForm.value = { ...scheduleList.value[idx] }
editingIndex.value = idx
scheduleDialogVisible.value = true
}
function saveSchedule() {
if (!scheduleForm.value.date || !scheduleForm.value.time || !scheduleForm.value.event || !scheduleForm.value.location) {
ElMessage.warning('请填写完整信息')
return
}
if (editingIndex.value === -1) {
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) {
// 使
const username = currentUser?.value?.username || null
updateOC(props.oc.id, { schedule: scheduleList.value }, username)
}
}
async function generateScheduleAI() {
if (!props.oc) return
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 || '未知错误'))
}
}
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')}`
}
function scheduleRowClass({ rowIndex }) {
return rowIndex % 2 === 0 ? 'even-row' : 'odd-row'
}
const emit = defineEmits(['update:modelValue'])
const visible = ref(props.modelValue)
const router = useRouter()
const defaultAvatar = 'https://i.pravatar.cc/100?img=1'
watch(() => props.modelValue, v => (visible.value = v))
watch(visible, v => emit('update:modelValue', v))
const handleEdit = () => {
// OCoc id
router.push({ name: 'OCCreationPage', query: { edit: 1, id: props.oc.id } })
visible.value = false
}
const onClose = () => {
visible.value = false
}
</script>
<style scoped>
.oc-info-main {
padding: 8px 0 0 0;
}
.oc-info-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 8px;
}
.oc-avatar {
width: 64px;
height: 64px;
border-radius: 12px;
object-fit: cover;
border: 1px solid #eee;
}
.oc-info-basic {
flex: 1;
}
.oc-name {
font-size: 20px;
font-weight: 600;
margin-bottom: 2px;
}
.oc-meta {
color: #909399;
font-size: 13px;
}
.oc-info-row {
margin-bottom: 8px;
font-size: 15px;
}
.schedule-area {
.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;
margin-bottom: 8px;
}
.oc-schedule-table {
margin-bottom: 8px;
border-radius: 8px;
overflow: hidden;
}
.oc-schedule-table .even-row {
background: #f7faff;
}
.oc-schedule-table .odd-row {
background: #fff;
}
.event-content {
font-weight: 500;
color: #409eff;
}
.schedule-actions {
display: flex;
gap: 10px;
margin-top: 8px;
justify-content: flex-end;
}
}
</style>

@ -1,255 +0,0 @@
<template>
<el-dialog
v-model="visible"
title="OC列表"
width="800px"
:before-close="handleClose"
append-to-body
>
<div class="oc-list">
<!-- 创建OC按钮 -->
<div class="create-section">
<el-button
type="primary"
size="large"
@click="createNewOC"
icon="Plus"
class="create-btn"
>
创建新OC
</el-button>
</div>
<!-- OC列表 -->
<div class="oc-grid" v-if="ocList.length > 0">
<div
v-for="oc in ocList"
:key="oc.id"
class="oc-card"
@click="viewOCDetail(oc)"
>
<div class="oc-avatar">
<el-avatar :size="60" :src="oc.avatar" />
</div>
<div class="oc-info">
<h4>{{ oc.name }}</h4>
<p class="oc-occupation">{{ oc.occupation }}</p>
<p class="oc-birthday">生日{{ oc.birthday }}</p>
<div class="oc-tags">
<el-tag
v-for="hobby in oc.hobbies.slice(0, 2)"
:key="hobby"
size="small"
class="hobby-tag"
>
{{ hobby }}
</el-tag>
</div>
</div>
<div class="oc-actions">
<el-button
type="text"
size="small"
@click.stop="editOC(oc)"
>
编辑
</el-button>
<el-button
type="text"
size="small"
@click.stop="handleDeleteOC(oc.id)"
class="delete-btn"
>
删除
</el-button>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-else class="empty-state">
<el-empty description="还没有创建任何OC">
<el-button type="primary" @click="createNewOC">
创建第一个OC
</el-button>
</el-empty>
</div>
</div>
<!-- OC信息弹窗 -->
<OCInfoDialog
v-model="infoDialogVisible"
:oc="selectedOC"
/>
<template #footer>
<el-button @click="handleClose"></el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ocList, deleteOC, loadOCFromStorage } from '../stores/ocStore.js'
import { currentUser } from '../stores/userStore.js'
import OCInfoDialog from './OCInfoDialog.vue'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue'])
const router = useRouter()
const visible = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value)
})
// OC
const infoDialogVisible = ref(false)
const selectedOC = ref(null)
// OC
watch(visible, (newVal) => {
if (newVal && currentUser.value.username) {
loadOCFromStorage(currentUser.value.username)
}
})
const handleClose = () => {
visible.value = false
}
const createNewOC = () => {
// OC
router.push('/create-oc')
visible.value = false
}
const viewOCDetail = (oc) => {
selectedOC.value = oc
infoDialogVisible.value = true
}
const editOC = (oc) => {
ElMessage.info(`编辑OC${oc.name}`)
// OC
}
const handleDeleteOC = (id) => {
ElMessageBox.confirm('确定要删除这个OC吗', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
if (deleteOC(id, currentUser.value.username)) {
ElMessage.success('删除成功')
} else {
ElMessage.error('删除失败')
}
}).catch(() => {
//
})
}
</script>
<style scoped>
.oc-list {
padding: 20px 0;
}
.create-section {
text-align: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 1px solid #ebeef5;
}
.create-btn {
width: 200px;
}
.oc-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
.oc-card {
display: flex;
align-items: center;
padding: 15px;
border: 1px solid #ebeef5;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
background: #fff;
}
.oc-card:hover {
border-color: #409eff;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.oc-avatar {
margin-right: 15px;
}
.oc-info {
flex: 1;
}
.oc-info h4 {
margin: 0 0 5px 0;
color: #303133;
font-size: 16px;
}
.oc-occupation {
margin: 0 0 5px 0;
color: #606266;
font-size: 14px;
}
.oc-birthday {
margin: 0 0 10px 0;
color: #909399;
font-size: 12px;
}
.oc-tags {
display: flex;
gap: 5px;
flex-wrap: wrap;
}
.hobby-tag {
font-size: 11px;
}
.oc-actions {
display: flex;
flex-direction: column;
gap: 5px;
align-items: flex-end;
justify-content: center;
}
.delete-btn {
color: #f56c6c;
}
.empty-state {
text-align: center;
padding: 40px 0;
}
</style>

@ -1,31 +0,0 @@
<template>
<div class="phone-page">
<h2>手机</h2>
<el-alert title="这是独立的手机页面(也可在社区中以弹窗形式使用)" type="info" show-icon />
<div class="widget">
<PhoneWidget v-model="open" />
<el-button type="primary" @click="open = true">打开手机</el-button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import PhoneWidget from './PhoneWidget.vue'
const open = ref(true)
</script>
<style scoped>
.phone-page {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
}
.widget {
margin-top: 8px;
}
</style>

@ -1,258 +0,0 @@
<template>
<el-dialog
v-model="modelValueLocal"
title="手机通信"
width="900px"
destroy-on-close
append-to-body
>
<div class="chat-layout">
<div class="left">
<div class="left-header">
<img class="me-avatar" :src="selfAvatar" alt="me" />
<div class="me-meta">
<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>
</div>
<el-tabs v-model="leftTab" class="left-tabs">
<el-tab-pane label="聊天" name="chat" />
<el-tab-pane label="联系人" name="contacts" />
</el-tabs>
<ContactsList v-model="activeContactId" :contacts="contacts" :simple-mode="true" v-show="leftTab === 'contacts'" />
<div v-show="leftTab === 'chat'" class="chat-list">
<div
v-for="c in contacts"
:key="c.id"
class="contact-item"
:class="{ active: c.id === activeContactId }"
@click="selectContact(c.id)"
>
<img class="avatar" :src="c.avatar" alt="avatar" />
<div class="meta">
<div class="name">{{ c.name }}</div>
<div class="last">{{ c.lastMessage || '...' }}</div>
</div>
<div class="time" v-if="c.lastTime">{{ formatTime(c.lastTime) }}</div>
<el-badge v-if="c.unread" :value="c.unread" class="unread" />
</div>
</div>
</div>
<div class="right">
<ChatWindow
:active-contact="activeContact"
v-model="activeMessages"
:self-id="selfId"
:self-avatar="selfAvatar"
@send="handleSend"
@open-info="showInfoDialog"
/>
</div>
</div>
<template #footer>
<el-button @click="close"></el-button>
</template>
</el-dialog>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import ContactsList from './ContactsList.vue'
import ChatWindow from './ChatWindow.vue'
import { ocList } from '../stores/ocStore'
import { currentUser } from '../stores/userStore'
import OCInfoDialog from './OCInfoDialog.vue'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue'])
const modelValueLocal = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value)
})
// 使OC
const selfId = currentUser.value.username || 'me'
const selfAvatar = currentUser.value.avatar || 'https://avatars.githubusercontent.com/u/9919?v=4'
const contacts = ocList
// OC
const messagesByContact = ref({})
const activeContactId = ref(contacts.value.length > 0 ? contacts.value[0].id : '')
const activeContact = computed(() => contacts.value.find(c => c.id === activeContactId.value) || null)
// OC
const infoDialogVisible = ref(false)
const infoDialogOC = computed(() => activeContact.value)
const showInfoDialog = () => {
//
if (!activeContact.value && contacts.value.length > 0) {
activeContactId.value = contacts.value[0].id
}
if (activeContact.value) {
infoDialogVisible.value = true
} else {
ElMessage.warning('暂无可查看的OC信息')
}
}
const leftTab = ref('chat')
const activeMessages = computed({
get: () => messagesByContact.value[activeContactId.value] || [],
set: val => {
messagesByContact.value[activeContactId.value] = val
}
})
const close = () => {
modelValueLocal.value = false
}
const selectContact = (contactId) => {
activeContactId.value = contactId
}
const formatTime = (ts) => {
try {
const d = new Date(ts)
const h = String(d.getHours()).padStart(2, '0')
const m = String(d.getMinutes()).padStart(2, '0')
return `${h}:${m}`
} catch (e) {
return ''
}
}
const handleSend = async ({ contact, message }) => {
const idx = contacts.value.findIndex(c => c.id === contact.id)
if (idx !== -1) {
contacts.value[idx] = {
...contacts.value[idx],
lastMessage: message.text,
lastTime: message.timestamp
}
}
// AIChatWindowAI
}
// clear unread when switching to a contact
watch(activeContactId, id => {
const i = contacts.value.findIndex(c => c.id === id)
if (i !== -1) {
contacts.value[i] = { ...contacts.value[i], unread: 0 }
}
})
</script>
<!-- OC信息弹窗 -->
<OCInfoDialog
v-model="infoDialogVisible"
:oc="infoDialogOC"
/>
<style scoped>
.chat-layout {
display: flex;
height: 600px;
background: #f5f7fa;
}
.left {
width: 280px;
height: 100%;
}
.left-header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: #fff;
border-bottom: 1px solid #ebeef5;
}
.me-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
}
.me-name {
font-weight: 600;
}
.me-sub {
font-size: 12px;
color: #909399;
}
.left-tabs {
background: #fff;
border-bottom: 1px solid #ebeef5;
}
.right {
flex: 1;
height: 100%;
}
.chat-list {
height: 100%;
overflow-y: auto;
background: #fff;
}
.contact-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
}
.contact-item:hover {
background: #f5f7fa;
}
.contact-item.active {
background: #ecf5ff;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 8px;
}
.meta {
flex: 1;
min-width: 0;
}
.name {
font-weight: 600;
font-size: 14px;
}
.last {
font-size: 12px;
color: #909399;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.time {
font-size: 12px;
color: #909399;
}
</style>

@ -1,230 +0,0 @@
<template>
<el-dialog
v-model="visible"
title="用户中心"
width="500px"
:before-close="handleClose"
append-to-body
>
<div class="user-center">
<!-- 用户头像和基本信息 -->
<div class="user-info">
<div class="avatar-section">
<el-avatar :size="80" :src="userInfo.avatar" />
<el-button
type="text"
size="small"
@click="changeAvatar"
class="change-avatar-btn"
>
更换头像
</el-button>
</div>
<div class="user-details">
<h3>{{ userInfo.nickname }}</h3>
<p class="username">@{{ userInfo.username }}</p>
</div>
</div>
<!-- 个人信息 -->
<div class="personal-info">
<h4>个人信息</h4>
<el-form :model="userInfo" label-width="80px" size="small">
<el-form-item label="生日">
<el-date-picker
v-model="userInfo.birthday"
type="date"
placeholder="选择生日"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
/>
</el-form-item>
<el-form-item label="职业">
<el-input v-model="userInfo.occupation" placeholder="请输入职业" />
</el-form-item>
<el-form-item label="喜好">
<el-input
v-model="userInfo.hobbies"
type="textarea"
:rows="2"
placeholder="请输入你的喜好"
/>
</el-form-item>
<el-form-item label="个人简介">
<el-input
v-model="userInfo.bio"
type="textarea"
:rows="3"
placeholder="介绍一下自己吧"
/>
</el-form-item>
</el-form>
</div>
<!-- 操作按钮 -->
<div class="actions">
<el-button type="primary" @click="saveProfile"></el-button>
<el-button @click="handleClose"></el-button>
</div>
<!-- 退出登录按钮 -->
<div class="logout-section">
<el-button
type="danger"
plain
@click="handleLogout"
class="logout-btn"
>
退出登录
</el-button>
</div>
</div>
</el-dialog>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { currentUser, updateUserInfo, logout } from '../stores/userStore.js'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue'])
const router = useRouter()
const visible = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value)
})
//
const userInfo = ref({ ...currentUser.value })
//
const originalUserInfo = ref({})
//
watch(visible, (newVal) => {
if (newVal) {
// OC
const currentUserData = currentUser.value
userInfo.value = {
username: currentUserData.username || '',
nickname: currentUserData.nickname || currentUserData.username || '',
avatar: currentUserData.avatar || 'https://avatars.githubusercontent.com/u/9919?v=4',
birthday: currentUserData.birthday || '2000-01-01',
occupation: currentUserData.occupation || '学生',
hobbies: currentUserData.hobbies || '编程、游戏、音乐',
bio: currentUserData.bio || '这是一个热爱编程的用户',
isLoggedIn: currentUserData.isLoggedIn || false
}
originalUserInfo.value = JSON.parse(JSON.stringify(userInfo.value))
}
})
const handleClose = () => {
//
userInfo.value = JSON.parse(JSON.stringify(originalUserInfo.value))
visible.value = false
}
const changeAvatar = () => {
ElMessage.info('头像更换功能待实现')
}
const saveProfile = () => {
//
updateUserInfo(userInfo.value)
ElMessage.success('资料保存成功')
visible.value = false
}
const handleLogout = () => {
ElMessageBox.confirm('确定要退出登录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
// 退
logout()
ElMessage.success('已退出登录')
visible.value = false
//
router.push('/login')
}).catch(() => {
//
})
}
</script>
<style scoped>
.user-center {
padding: 20px 0;
}
.user-info {
display: flex;
align-items: center;
gap: 20px;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 1px solid #ebeef5;
}
.avatar-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.change-avatar-btn {
font-size: 12px;
}
.user-details h3 {
margin: 0 0 5px 0;
color: #303133;
}
.username {
margin: 0;
color: #909399;
font-size: 14px;
}
.personal-info {
margin-bottom: 30px;
}
.personal-info h4 {
margin: 0 0 15px 0;
color: #303133;
font-size: 16px;
}
.actions {
display: flex;
justify-content: center;
gap: 10px;
margin-bottom: 20px;
}
.logout-section {
text-align: center;
padding-top: 20px;
border-top: 1px solid #ebeef5;
}
.logout-btn {
width: 200px;
}
</style>

@ -1,12 +0,0 @@
import { createApp } from 'vue'
import App from './App.vue'
// 添加这两行来引入 Element Plus
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import router from './router'
const app = createApp(App)
app.use(ElementPlus) // 使用 Element Plus
app.use(router)
app.mount('#app')

@ -1,69 +0,0 @@
import { createRouter, createWebHistory } from 'vue-router'
import { isLoggedIn } from '../stores/userStore.js'
// 1. 导入页面组件
import LoginPage from '../components/LoginPage.vue'
import OCCreationPage from '../components/OCCreationPage.vue'
import HomePage from '../components/HomePage.vue'
import PhonePage from '../components/PhonePage.vue'
import CommunityPage from '../components/CommunityPage.vue'
// 2. 定义路由规则
const routes = [
{
path: '/', // 访问路径
redirect: '/login' // 重定向到登录页
},
{
path: '/login', // 访问路径http://localhost:5173/login
name: 'Login', // 路由名称
component: LoginPage // 对应的组件
},
{
path: '/home', // 首页
name: 'Home',
component: HomePage
},
{
path: '/create-oc', // 访问路径http://localhost:5173/create-oc
name: 'OCCreationPage',
component: OCCreationPage
},
{
path: '/community', // 访问路径http://localhost:5173/community
name: 'Community',
component: CommunityPage
},
{
path: '/phone',
name: 'Phone',
component: PhonePage
}
]
// 3. 创建路由实例
const router = createRouter({
history: createWebHistory(),
routes
})
// 4. 添加路由守卫
router.beforeEach((to, from, next) => {
// 如果访问登录页,直接通过
if (to.path === '/login') {
next()
return
}
// 检查是否已登录
if (!isLoggedIn.value) {
// 未登录,重定向到登录页
next('/login')
} else {
// 已登录,正常访问
next()
}
})
// 5. 导出路由
export default router

@ -1,136 +0,0 @@
import { ref } from 'vue'
// OC状态管理
const ocList = ref([
// 示例数据
{
id: 1,
name: '小艾',
avatar: 'https://i.pravatar.cc/100?img=1',
occupation: '程序员',
birthday: '1998-05-15',
hobbies: ['编程', '阅读', '音乐'],
bio: '热爱编程的AI助手',
schedule: [],
achievements: [],
socialCircle: []
},
{
id: 2,
name: '小智',
avatar: 'https://i.pravatar.cc/100?img=2',
occupation: '设计师',
birthday: '1999-08-22',
hobbies: ['绘画', '摄影', '旅行'],
bio: '充满创意的设计师',
schedule: [],
achievements: [],
socialCircle: []
}
])
// 从本地存储加载OC列表
const loadOCFromStorage = (username = null) => {
let storageKey = 'ocList'
if (username) {
storageKey = `ocList_${username}`
}
const storedOC = localStorage.getItem(storageKey)
if (storedOC) {
try {
ocList.value = JSON.parse(storedOC)
} catch (error) {
console.error('Failed to parse OC list from storage:', error)
}
} else {
// 如果没有找到用户特定的OC列表使用默认的
if (username) {
// 尝试从通用键迁移(兼容早期版本),如果通用键存在则使用并写入用户特定键
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) => {
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)
}
}
// 添加新OC
const addOC = (ocData, username = null) => {
const newOC = {
id: Date.now(), // 简单的ID生成
...ocData,
schedule: [],
achievements: [],
socialCircle: []
}
// 允许无限添加OC不做数量限制
ocList.value.push(newOC)
saveOCToStorage(username)
return newOC
}
// 更新OC信息
const updateOC = (id, updatedData, username = null) => {
const index = ocList.value.findIndex(oc => oc.id === id)
if (index !== -1) {
// 就地合并到原对象,保持引用稳定
Object.assign(ocList.value[index], updatedData)
saveOCToStorage(username)
return ocList.value[index]
}
return null
}
// 删除OC
const deleteOC = (id, username = null) => {
const index = ocList.value.findIndex(oc => oc.id === id)
if (index !== -1) {
ocList.value.splice(index, 1)
saveOCToStorage(username)
return true
}
return false
}
// 根据ID获取OC
const getOCById = (id) => {
return ocList.value.find(oc => oc.id === id)
}
// 初始化时加载数据
loadOCFromStorage()
export {
ocList,
addOC,
updateOC,
deleteOC,
getOCById,
loadOCFromStorage
}

@ -1,264 +0,0 @@
import { ref, computed } from 'vue'
// 用户状态管理
const userInfo = ref({
username: '',
nickname: '',
avatar: '',
birthday: '',
occupation: '',
hobbies: '',
bio: '',
isLoggedIn: false
})
// 从本地存储加载用户信息
const loadUserFromStorage = (username = null, clearOnError = true) => {
if (username) {
const userInfoKey = `userInfo_${username}`
const storedUser = localStorage.getItem(userInfoKey)
const storedToken = localStorage.getItem('userToken')
if (storedUser && storedToken) {
try {
const user = JSON.parse(storedUser)
userInfo.value = {
...user,
isLoggedIn: true
}
} catch (error) {
console.error('Failed to parse user info from storage:', error)
if (clearOnError) {
clearUserData()
}
}
}
} else {
// 兼容旧版本,从通用存储加载
const storedUser = localStorage.getItem('userInfo')
const storedToken = localStorage.getItem('userToken')
if (storedUser && storedToken) {
try {
const user = JSON.parse(storedUser)
userInfo.value = {
...user,
isLoggedIn: true
}
} catch (error) {
console.error('Failed to parse user info from storage:', error)
if (clearOnError) {
clearUserData()
}
}
}
}
}
// 保存用户信息到本地存储
const saveUserToStorage = (userData) => {
// 为每个用户创建独立的用户信息存储
if (userData.username) {
const userInfoKey = `userInfo_${userData.username}`
localStorage.setItem(userInfoKey, JSON.stringify(userData))
// 只在登录时更新token更新用户信息时不更新token
if (userData.isLoggedIn && !localStorage.getItem('userToken')) {
localStorage.setItem('userToken', 'mock-token-' + Date.now())
}
// 为每个用户创建独立的OC存储键
const userOCKey = `ocList_${userData.username}`
if (!localStorage.getItem(userOCKey)) {
localStorage.setItem(userOCKey, JSON.stringify([]))
}
}
}
// 清除用户数据
const clearUserData = () => {
// 只清除通用存储(兼容旧版本),不删除用户详细信息
localStorage.removeItem('userInfo')
localStorage.removeItem('userToken')
userInfo.value = {
username: '',
nickname: '',
avatar: '',
birthday: '',
occupation: '',
hobbies: '',
bio: '',
isLoggedIn: false
}
}
// 登录
const login = (loginData) => {
return new Promise((resolve, reject) => {
// 模拟登录API调用
setTimeout(() => {
if (loginData.username && loginData.password) {
// 首先检查用户是否已注册
const userRegistryKey = `userRegistry_${loginData.username}`
const existingRegistry = localStorage.getItem(userRegistryKey)
if (!existingRegistry) {
reject({ success: false, message: '用户不存在,请先注册' })
return
}
// 校验密码
let registryObj = null
try {
registryObj = JSON.parse(existingRegistry)
} catch (e) {
reject({ success: false, message: '用户注册信息损坏,请重新注册' })
return
}
if (!registryObj.password || registryObj.password !== loginData.password) {
reject({ success: false, message: '密码错误' })
return
}
// 优先从用户特定的存储中加载用户数据
const userInfoKey = `userInfo_${loginData.username}`
let storedUser = localStorage.getItem(userInfoKey)
let userData = null;
if (storedUser) {
try {
const existingUser = JSON.parse(storedUser)
userData = {
...existingUser,
isLoggedIn: true
}
} catch (error) {
// 解析失败,自动用注册信息生成默认用户信息
userData = {
username: registryObj.username,
nickname: registryObj.username,
avatar: 'https://avatars.githubusercontent.com/u/9919?v=4',
birthday: '2000-01-01',
occupation: '未知',
hobbies: 'ta一点也没透露',
bio: '这是一个新用户',
isLoggedIn: true
}
// 自动修复本地存储
localStorage.setItem(userInfoKey, JSON.stringify(userData))
}
} else {
// userInfo_用户名 丢失,自动用注册信息生成默认用户信息
userData = {
username: registryObj.username,
nickname: registryObj.username,
avatar: 'https://avatars.githubusercontent.com/u/9919?v=4',
birthday: '2000-01-01',
occupation: '未知',
hobbies: 'ta一点也没透露',
bio: '这是一个新用户',
isLoggedIn: true
}
// 自动修复本地存储
localStorage.setItem(userInfoKey, JSON.stringify(userData))
}
userInfo.value = userData
saveUserToStorage(userData)
// 同时更新通用存储以保持兼容性
localStorage.setItem('userInfo', JSON.stringify(userData))
resolve({ success: true, user: userData })
} else {
reject({ success: false, message: '用户名和密码不能为空' })
}
}, 1000)
})
}
// 注册
const register = (registerData) => {
return new Promise((resolve, reject) => {
// 模拟注册API调用
setTimeout(() => {
if (registerData.username && registerData.password) {
// 检查用户名是否已存在 - 使用独立的用户注册记录
const userRegistryKey = `userRegistry_${registerData.username}`
const existingRegistry = localStorage.getItem(userRegistryKey)
if (existingRegistry) {
reject({ success: false, message: '用户名已存在,请选择其他用户名' })
return
}
const userData = {
username: registerData.username,
nickname: registerData.nickname || registerData.username,
avatar: registerData.avatar || 'https://avatars.githubusercontent.com/u/9919?v=4',
birthday: registerData.birthday || '2000-01-01',
occupation: registerData.occupation || '未知',
hobbies: registerData.hobbies || 'ta一点也没透露',
bio: registerData.bio || '这是一个新用户',
isLoggedIn: true
}
// 保存用户注册记录(包含密码)
localStorage.setItem(userRegistryKey, JSON.stringify({
username: registerData.username,
password: registerData.password,
registeredAt: Date.now()
}))
userInfo.value = userData
saveUserToStorage(userData)
// 同时更新通用存储以保持兼容性
localStorage.setItem('userInfo', JSON.stringify(userData))
console.log('注册调试 - 保存的用户信息键:', `userInfo_${userData.username}`)
console.log('注册调试 - 保存的用户信息:', localStorage.getItem(`userInfo_${userData.username}`))
console.log('注册调试 - 所有用户信息键:', Object.keys(localStorage).filter(key => key.startsWith('userInfo_')))
resolve({ success: true, user: userData })
} else {
reject({ success: false, message: '用户名和密码不能为空' })
}
}, 1000)
})
}
// 更新用户信息
const updateUserInfo = (newUserInfo) => {
const updatedUser = {
...userInfo.value,
...newUserInfo
}
userInfo.value = updatedUser
saveUserToStorage(updatedUser)
// 同时更新通用存储以保持兼容性
localStorage.setItem('userInfo', JSON.stringify(updatedUser))
}
// 退出登录
const logout = () => {
clearUserData()
}
// 计算属性
const isLoggedIn = computed(() => userInfo.value.isLoggedIn)
const currentUser = computed(() => userInfo.value)
// 初始化时加载用户数据 - 从通用存储加载以保持兼容性,不清除数据
loadUserFromStorage(null, false)
export {
userInfo,
isLoggedIn,
currentUser,
login,
register,
updateUserInfo,
logout,
loadUserFromStorage
}

@ -1,79 +0,0 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

@ -1,7 +0,0 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
})

@ -1,220 +0,0 @@
大学生软工课设 自定义ai伙伴-oc社区 上帝视角
用户登录
用户可以自主设定一个“我”角色 自定义外观 日程表 生日 喜好 职业等等
创建oc 填写oc问卷或交给ai随机生成oc上传oc图片或通过填写问卷ai生成图片作为当前oc的头像。
确定oc头像后进入社区界面用户可以自定义oc的日程表或者让ai根据填写的问卷生成日程表游戏内时间需要与用户所处实际时间相同oc会根据自己的日程表安排自己的活动。小屋中包含卧室客厅厨房健身房餐厅超市花店体育馆酒店等等可拓展性活动场所所有oc都在社区里生活工作甚至学习上课社区里也有一些aiNPC和用户的oc一起生活。
进入手机界面用户可以给oc或已结识的NPC发消息进行交流ai会根据oc自身日程表当前的活动方式决策是实时回复还是延时回复比如oc当前正在休闲放松可以实时回复若进行体育运动如游泳就要延时回复NPC同理。
社区地图 暂定包含以下地点超市主卖日常用品饭店电影院商场综合性公寓个人空间甜品店植物园公园体育馆娱乐休闲区KTV台球厅电玩城棋牌室酒吧等宠物店。
我希望打开网站后首先出现登录注册页面,完成登录后直接显示社区界面,在当前界面右侧一排从上到下有三个按钮 依次是用户中心 oc列表 手机通信,
点击用户中心会出现用户中心弹窗,弹窗中包含用户头像,用户昵称和一些自我角色的个人信息,下拉最下方有退出登录按钮,点击后会回到登录注册页面。
点击oc列表出现oc列表界面弹窗初始列表为空白有一个“创建oc”按钮点击按钮后出现oc问卷用户完成问卷填写后点击提交oc信息会交给ai进行模拟出现oc头像选择窗口用户可以选择上传本地图片或者使用ai根据表单提交的信息生成。头像选择完毕后点击确定。
回到用户列表此时列表中会显示出刚创建的o头像及名称集成卡片点击卡片会出现oc的信息界面包含oc头像oc名称oc生日oc喜好oc职业oc日程表oc的社交圈oc的成就等等用户可以点击日程表进行修改点击社交圈可以添加好友点击成就可以查看自己或好友的成就。
OC角色创建问卷
欢迎来到OC创造者请填写以下问卷帮助我们了解你想要的OC角色。如果不想手动填写可以点击“AI随机生成”按钮自动创建。
1. 基础信息
角色名称_________________________
性别:□ 男 □ 女 □ 其他 _______
生日___月 ___日
年龄_________________________
种族人类、精灵、兽人、恶魔、机器人等可自定义_________________________
职业学生、骑士、程序员、魔法师、偶像、侦探等_________________________
2. 外表形象用于AI生成图片和视觉想象
发型与发色银色短发、金色长卷发、蓝色双马尾_________________________
瞳色琥珀色、湛蓝色、异色瞳_________________________
大致身高_________________________
着装风格休闲运动、哥特礼服、未来科技、古典汉服、学院风_________________________
标志性特征眼角的泪痣、总戴着兜帽、有一条机械臂、有猫耳和尾巴_________________________
3. 性格与内心这是AI对话和行为逻辑的核心
核心性格请选择1-3项
□ 开朗外向 □ 安静内向 □ 成熟稳重 □ 天真烂漫 □ 理性冷静
□ 感性冲动 □ 温柔体贴 □ 傲娇冷淡 □ 幽默搞怪 □ 认真固执
□ 悲观消极 □ 乐观积极 □ 腹黑 □ 忠犬 □ 其他 _______
MBTI人格类型可选_________________________ (e.g., INFP, ESTJ)
最大的优点_________________________
最大的缺点_________________________
习惯性小动作思考时捻头发、紧张时会结巴、说谎时眼神飘忽_________________________
口头禅“没问题”、“让我想想…”、“真是无聊”_________________________
4. 背景故事与关系(让角色更真实丰满)
成长环境繁华都市、宁静乡村、孤儿院长大、贵族世家、星际飞船_________________________
一段简要的背景故事或重要经历:
想要隐藏的秘密可选_________________________
5. 喜好与日常(用于生成日程表和对话内容)
兴趣爱好阅读、健身、烹饪、打游戏、观星、写诗_________________________
喜欢的食物_________________________
讨厌的东西_________________________
理想的生活方式悠闲度日、不断挑战自我、为社会做贡献、陪伴重要的人_________________________
通常如何度过周末_________________________
6. 社区角色期待决定OC在社区中的行为
在社区中你希望TA主要从事什么活动在花店打工、在健身房锻炼、在图书馆学习、在广场闲逛
你希望与TA建立什么样的关系
□ 亲密无间的朋友 □ 互相扶持的伙伴 □ 需要照顾的后辈
□ 值得信赖的前辈 □ 略带距离感的熟人 □ 其他 _______
提交 | AI随机生成 | 清空重填 | 取消创建
功能概述:
已有功能(补充一句话描述)
注册:用户可通过注册页面创建新账号。
登录:用户输入账号密码后进入社区平台。
修改个人信息:用户可随时编辑自己的昵称、头像、生日等资料。
修改个人日程表:用户可自定义或调整自己的日常活动安排。
退出登录:用户可安全退出当前账号,返回登录页。
创建OC用户可通过问卷或AI生成方式创建属于自己的OC角色。
管理OC用户可编辑或删除已创建OC的详细资料。
修改OC日程表用户可为每个OC单独设定或调整日程安排。
AI互动用户可与OC或NPC通过AI进行智能对话。
可补充的功能及描述
好友系统:用户可添加其他用户为好友,建立社交关系。
社区动态/广场用户和OC可在社区内发布动态、评论和点赞。
成就系统用户和OC可通过完成特定任务获得成就徽章。
消息通知:系统可推送好友请求、活动提醒等通知。
*多社区/多世界切换:用户可创建或加入不同主题的社区世界。
*社区活动/事件:定期举办线上活动或社区事件,丰富互动体验。
*自定义社区场景:用户可自定义社区的外观、布局和活动场所。
虚拟经济系统:引入虚拟货币,用于购买装饰、道具等。
商城系统:用户可在商城购买服饰、家具等虚拟物品。
排行榜/活跃榜展示用户或OC的活跃度、成就排名。
数据备份与恢复:支持用户数据的云端备份与恢复。
多端同步支持PC端、移动端数据实时同步。
黑名单/举报功能:用户可屏蔽或举报不良行为。
多语言支持:平台支持多种语言切换,适应不同用户群体。
夜间模式:支持界面夜间/白天主题切换。
API开放/插件扩展为开发者提供API或插件接口便于二次开发。
*新手引导/帮助中心:为新用户提供操作指引和常见问题解答。
数据统计与分析为用户提供社区活跃度、OC成长等数据可视化。
拓展构想:
多个世界,多个社区,自定义社区场景和社区活动,自定义自然环境和社会背景(与实时性相悖?)
与时俱进 增加每日大事件和新闻功能
联机功能 邀请朋友加入自己的世界或加入朋友的世界
oc问卷内容
版本问题
--0.1目标:搭建平台雏形
0.0.1
按钮没有对其
oc创建后没有显示在列表中
用户注册完成后应当回到登录界面进行登录,而不是直接进入社区界面
已注册的用户再次登录时,个人信息出现错误
0.0.2
按钮仍没有对齐 用户中心按钮稍微考前了一点
用户登陆后的信息仍旧没有储存 每个用户创建的oc信息应当不是共享的 而是独立的
登录另一个用户后前一个用户的注册信息竟然变成了oc信息 没有注册的用户应当不可以登录
0.0.3
创建oc填写oc信息界面需要增加取消创建按钮 按下后可以返回oc列表界面
手机界面中点击导航栏的“联系人按钮”后应当跳转到一个类似微信的联系人列表,在列表中选择联系人后,右侧出现和所选联系人的聊天界面
已注册过的账号退出登录后再注册出现问题 显示用户不存在
0.0.4
用户的个人信息应当与注册信息一致,只有在修改并保存后变化,再次登录后个人信息也应当和原来保持一致
要求用户注册后的信息可以正常登录并保存在用户中心中,可以随时修改或查看
联系人列表只要有联系人头像名称就好,不显示最近一次聊天内容和时间
0.1.0--完成了整体意义上的平台雏形
0.1.1
成功调用api进行对话将手机中的联系人替换成用户自己创建的ocai回复逻辑参考oc性格回复语句模拟现实聊天简洁轻快
oc问卷中生日只要月日即可不需要填写年份且ai随机生成的内容并没有同步到表单中
0.1.2
oc问卷ai随机生成成功实现但创建oc数量好像有最大限制超过4个后新创建的oc会覆盖掉最后一个
头像上传和生成功能有待实现 头像生成根据喜好和性格上传或ai生成头像后可以修改头像
在聊天界面头像显示过大导致界面显示不完整,需要调整头像大小
对oc共享我的信息辅助ai聊天
删除手机界面中的语音和视频按钮点击资料按钮可以查看oc的信息
0.1.3
帮我写一个oc信息界面可以点击oc列表中的oc名片或在手机界面中点击“资料”按钮进入
进入后仅显示oc基本信息包含头像 姓名 性别 生日 年龄 喜好 关系,以及日程表区域)
根据oc问卷信息显示基本信息设置编辑按钮点击后跳转到oc问卷界面可对问卷信息进行修改并保存
在聊天界面头像显示过大导致聊天信息显示不完整,需要规范头像大小
0.1.4
调用ai生成日程表根据oc性格和喜好生成一个随机的日程表并显示在oc信息界面中点击日程表可以查看或修改具体日程
将社区地图图片替换成map社区地点上方有坐标点地点名称所在人数根据所有oc的日程表中与现实时间相比较在同一天且相差时间最少的时间点的活动地点与当前地点一致则判断该oc当前在这个地点点击坐标点后显示所在的oc头像点击对应oc头像可查看oc信息
日程表生成后要保存到数据库中下次进入oc信息界面时可以直接显示,日程表在任何情况下显示时要与最近一次生成内容保持同步
0.1.5
社区地图的地点弹窗中点击对应oc头像可查看oc信息--界面跳转
手机中点资料可以查看oc信息聊天窗口增加状态栏实时显示oc活动状态ai根据日程状态延时回复比如oc当前正在休闲放松可以实时回复若进行体育运动如游泳就要延时回复
修改个人昵称不同用户登录地图上不显示非当前用户oc的活动--个人信息与oc日程信息关联
导入地区地图和实时天气,并显示在社区地图界面中--一种尝试
ai生成oc头像--AI绘图
##ai记忆聊天历史并根据聊天历史进行回复迁移数据库
自主构建地图
--0.2目标成功调用ai搭建基本社区地图
--0.3目标:界面美化
列表抽拉栏
cursor重置方案
方案一:机器码全量重置,并禁用更新(推荐)
📌 实现原理
Terminal window
## Windows系统操作PowerShell需管理员权限
irm https://raw.githubusercontent.com/yuaotian/go-cursor-help/refs/heads/master/scripts/run/cursor_win_id_modifier.ps1 | iex
界面:
登录界面
注册界面
社区界面
个人信息界面
oc列表界面
oc信息界面
oc问卷界面
手机界面
Loading…
Cancel
Save