wangfukang 4 weeks ago
parent
commit
beb4931f3c
  1. 791
      package1/ieBrowser/chat.vue
  2. 147
      package1/ieBrowser/chatList.vue
  3. 34
      package1/ieBrowser/companion.vue
  4. 64
      package1/ieBrowser/fate.vue
  5. 10
      package1/ieBrowser/friends.vue
  6. 289
      package1/ieBrowser/index.vue
  7. 40
      package1/ieBrowser/match.vue
  8. 4
      package1/ieBrowser/matching.vue
  9. 69
      package1/ieBrowser/messages.vue
  10. 248
      package1/ieBrowser/profileSetup.vue
  11. 2
      package1/ieBrowser/settings.vue
  12. 33
      package1/ieBrowser/universe.vue

791
package1/ieBrowser/chat.vue

@ -4,9 +4,13 @@
<view class="glow glow-b"></view>
<view class="nav" :style="{ paddingTop: menuButtonInfo.top + 'px' }">
<view class="back" @tap="back"></view>
<view class="room-user">
<view class="nav-avatar-wrap" @tap="openTargetProfile">
<image class="nav-avatar-img" v-if="companion.avatarUrl" :src="companion.avatarUrl" mode="aspectFill"></image>
<view class="nav-avatar" v-else>{{ companion.avatar }}</view>
</view>
<view class="room-user" @tap="openTargetProfile">
<view class="room-name">{{ companion.name }}</view>
<view class="room-meta">{{ roomModeText }} · {{ countdownText }} 后自然结束</view>
<view class="room-meta">{{ roomModeText }} · 可以随时回来继续聊</view>
</view>
<view class="more" @tap="showSafety = true">···</view>
</view>
@ -22,10 +26,10 @@
<view class="timer-card">
<view>
<view class="timer-label">沉默陪伴模式</view>
<view class="timer-label">长期陪伴频道</view>
<view class="timer-title">{{ companion.prompt }}</view>
</view>
<view class="timer-pill">{{ countdownText }}</view>
<view class="timer-pill">已连接</view>
</view>
<view class="silent-modes">
@ -34,22 +38,29 @@
</view>
</view>
<view class="messages">
<scroll-view class="messages" scroll-y :scroll-into-view="scrollIntoView" :scroll-with-animation="scrollAnimation"
upper-threshold="80" @scrolltoupper="loadOlderMessages">
<view class="load-more" v-if="loadingMessages">正在加载更早消息...</view>
<view class="load-more" v-else-if="!hasMoreMessages && messages.length">没有更早的消息了</view>
<view class="system-line">你们被放进同一个频道不说话也算陪伴</view>
<view class="hesitate-line">对方删掉了一句话</view>
<view class="msg-row other">
<view class="avatar" :class="roomMode">{{ companion.avatar }}</view>
<view class="bubble">{{ companion.firstMessage }}</view>
</view>
<view class="msg-row mine">
<view class="bubble muted">我在刚好也想安静一会</view>
<view class="msg-row" v-for="item in messages" :key="item.domId"
:id="item.domId"
:class="item.mine ? 'mine' : 'other'">
<image class="avatar-img" v-if="!item.mine && companion.avatarUrl" :src="companion.avatarUrl" mode="aspectFill" @tap="openTargetProfile"></image>
<view class="avatar" :class="roomMode" v-else-if="!item.mine" @tap="openTargetProfile">{{ companion.avatar }}</view>
<view class="bubble image-bubble" v-if="item.messageType === 2" :class="{ muted: item.pending, blocked: item.blocked }">
<image class="chat-image" :src="item.content" mode="aspectFill" @tap="previewImage(item.content)"></image>
</view>
<view class="hesitate-line right">对方正在犹豫输入</view>
<view class="msg-row other">
<view class="avatar" :class="roomMode">{{ companion.avatar }}</view>
<view class="bubble">那就不用急着聊先一起待 15 分钟</view>
<view class="bubble emoji-bubble" v-else-if="item.messageType === 3" :class="{ muted: item.pending, blocked: item.blocked }">{{ item.content }}</view>
<view class="bubble voice-bubble" v-else-if="item.messageType === 4" :class="{ muted: item.pending, blocked: item.blocked, playing: playingVoiceUrl === item.content }"
@tap="playVoice(item)">
<text class="voice-wave">{{ playingVoiceUrl === item.content ? '▮▮▮' : '▮▯▮' }}</text>
<text>{{ item.mediaDuration || 1 }}''</text>
</view>
<view class="bubble" v-else :class="{ muted: item.pending, blocked: item.blocked }">{{ item.content }}</view>
</view>
<view class="hesitate-line right" v-if="typingHint">{{ typingHint }}</view>
</scroll-view>
<view class="presence-row">
<view class="presence-chip" v-for="item in presenceActions" :key="item" @tap="sendPresence(item)">
@ -57,9 +68,20 @@
</view>
</view>
<view class="emoji-panel" v-if="showEmoji">
<view class="emoji-item" v-for="item in emojis" :key="item" @tap="sendEmoji(item)">{{ item }}</view>
</view>
<view class="input-bar">
<input class="input" v-model="draft" placeholder="轻轻说一句,或保持安静" />
<view class="send" @tap="sendMessage">发送</view>
<view class="tool-btn" @tap="toggleEmoji"></view>
<view class="tool-btn" @tap="voiceMode = !voiceMode">{{ voiceMode ? '键' : '语' }}</view>
<input class="input" v-if="!voiceMode" v-model="draft" placeholder="轻轻说一句,或保持安静" />
<view class="voice-hold" v-else :class="{ recording: recording, cancelling: voiceCancelling }"
@touchstart.prevent="startRecord" @touchmove.prevent="moveRecord" @touchend="stopRecord" @touchcancel="cancelRecord">
{{ voiceHoldText }}
</view>
<view class="tool-btn" @tap="chooseImage"></view>
<view class="send" v-if="!voiceMode" @tap="sendMessage">发送</view>
</view>
<view class="safety-mask" v-if="showSafety" @tap="showSafety = false">
@ -70,10 +92,28 @@
<view class="safety-item quiet" @tap="finishRoom">提前结束陪伴</view>
</view>
</view>
<view class="profile-mask" v-if="showTargetProfile" @tap="showTargetProfile = false">
<view class="profile-panel" @tap.stop>
<image class="profile-avatar-img" v-if="targetProfile.avatarUrl" :src="targetProfile.avatarUrl" mode="aspectFill"></image>
<view class="profile-avatar" v-else>{{ targetProfile.avatarText || companion.avatar }}</view>
<view class="profile-name">{{ targetProfile.anonymousName || companion.name }}</view>
<view class="profile-mode">{{ targetProfile.currentMode === 'e' ? 'e 人 · 轻轻热闹' : 'i 人 · 安静陪伴' }}</view>
<view class="profile-intro">{{ targetProfile.intro || companion.prompt || '这个人还没有写介绍。' }}</view>
<view class="profile-tags" v-if="targetProfile.interestTags && targetProfile.interestTags.length">
<text v-for="tag in targetProfile.interestTags" :key="tag">{{ tag }}</text>
</view>
<view class="profile-close" @tap="showTargetProfile = false">继续聊天</view>
</view>
</view>
</view>
</template>
<script>
import ieSocket from '@/common/ieSocket.js'
import { finishIeRoom, reportIeRoom, blockIeUser, sendIePresence, pageIeMessages, sendIeMessage, getIeUserProfile } from '@/common/ieApi.js'
import tui from '@/common/httpRequest.js'
export default {
data() {
return {
@ -81,10 +121,38 @@
top: 24
},
roomMode: 'i',
roomId: '',
targetUserId: '',
secondsLeft: 15 * 60,
timer: null,
pollTimer: null,
draft: '',
showSafety: false,
showTargetProfile: false,
targetProfile: {},
showEmoji: false,
voiceMode: false,
typingHint: '',
finishing: false,
uploadingImage: false,
uploadingVoice: false,
recording: false,
voiceCancelling: false,
recordStartY: 0,
recordStartTime: 0,
recorderManager: null,
innerAudioContext: null,
playingVoiceUrl: '',
voiceSwitching: false,
messages: [],
messagePage: 1,
messagePageSize: 20,
hasMoreMessages: true,
loadingMessages: false,
scrollIntoView: '',
scrollAnimation: true,
sendingMessage: false,
emojis: ['🙂', '😄', '🥲', '😭', '😴', '🙌', '🌙', '☁️', '🍃', '✨', '💛', '🫶'],
presenceActions: ['我在', '听着呢', '慢慢说', '抱一下空气'],
silentModes: ['一起听歌', '一起倒计时', '一起自习', '一起失眠'],
companions: {
@ -117,6 +185,10 @@
const minutes = String(Math.floor(this.secondsLeft / 60)).padStart(2, '0')
const seconds = String(this.secondsLeft % 60).padStart(2, '0')
return `${minutes}:${seconds}`
},
voiceHoldText() {
if (!this.recording) return '按住说话'
return this.voiceCancelling ? '松开取消' : '松开发送,上滑取消'
}
},
onLoad(options) {
@ -126,21 +198,36 @@
if (options && options.mode) {
this.roomMode = options.mode
}
this.startTimer()
this.roomId = options.roomId || ''
this.targetUserId = options.targetUserId || ''
if (options.name || options.avatar || options.avatarUrl || options.quote) {
this.companions[this.roomMode] = {
...this.companion,
name: decodeURIComponent(options.name || this.companion.name),
avatar: decodeURIComponent(options.avatar || this.companion.avatar),
avatarUrl: decodeURIComponent(options.avatarUrl || ''),
prompt: decodeURIComponent(options.quote || this.companion.prompt),
firstMessage: ''
}
}
this.initRecorder()
this.initAudio()
this.loadTargetProfile()
this.loadLatestMessages()
this.initSocket()
this.startMessagePolling()
},
onUnload() {
this.clearTimer()
this.stopMessagePolling()
if (this.innerAudioContext) {
this.innerAudioContext.destroy()
this.innerAudioContext = null
}
},
methods: {
startTimer() {
this.clearTimer()
this.timer = setInterval(() => {
if (this.secondsLeft <= 0) {
this.finishRoom()
return
}
this.secondsLeft -= 1
}, 1000)
//
},
clearTimer() {
if (this.timer) {
@ -148,11 +235,115 @@
this.timer = null
}
},
sendPresence(text) {
uni.showToast({
title: `已发送:${text}`,
icon: 'none'
normalizeMessage(item) {
const currentUserId = String(uni.getStorageSync('id') || '')
const messageId = item.id || item.messageId
const clientMsgId = item.clientMsgId || ''
const mine = item.mine !== undefined && item.mine !== null ? item.mine : (currentUserId && String(item.senderId) === currentUserId)
return {
domId: 'msg-' + (messageId || clientMsgId || Date.now() + '-' + Math.random().toString(16).slice(2)),
messageId,
clientMsgId,
content: item.filteredContent || item.content || item.rawContent || '',
messageType: item.messageType || 1,
mediaDuration: item.mediaDuration,
mediaSize: item.mediaSize,
mediaFormat: item.mediaFormat,
mine,
pending: false,
blocked: item.isBlocked === 1
}
},
startMessagePolling() {
this.stopMessagePolling()
if (!this.roomId) return
this.pollTimer = setInterval(() => {
this.pullNewMessages()
}, 2200)
},
stopMessagePolling() {
if (this.pollTimer) {
clearInterval(this.pollTimer)
this.pollTimer = null
}
},
async pullNewMessages() {
if (!this.roomId || this.loadingMessages) return
const page = await pageIeMessages(this.roomId, 1, this.messagePageSize)
const latest = this.normalizePage(page)
if (!latest.length) return
const exists = new Set(this.messages.map(item => item.messageId || item.clientMsgId))
const appended = latest.filter(item => !exists.has(item.messageId || item.clientMsgId))
if (!appended.length) return
this.messages = this.messages.concat(appended)
this.scrollToBottom()
},
normalizePage(page) {
if (!page) return []
const records = Array.isArray(page) ? page : (page.records || [])
return records.map(this.normalizeMessage).reverse()
},
async loadLatestMessages() {
if (!this.roomId) return
this.messagePage = 1
this.loadingMessages = true
try {
const page = await pageIeMessages(this.roomId, this.messagePage, this.messagePageSize)
const latest = this.normalizePage(page)
this.messages = latest
this.hasMoreMessages = page ? (page.current || 1) < (page.pages || 1) : false
this.scrollToBottom(false)
} finally {
this.loadingMessages = false
}
},
async loadOlderMessages() {
if (!this.roomId || this.loadingMessages || !this.hasMoreMessages) return
const anchor = this.messages.length ? this.messages[0].domId : ''
this.loadingMessages = true
const nextPage = this.messagePage + 1
try {
const page = await pageIeMessages(this.roomId, nextPage, this.messagePageSize)
const older = this.normalizePage(page)
const exists = new Set(this.messages.map(item => item.messageId || item.clientMsgId))
const deduped = older.filter(item => !exists.has(item.messageId || item.clientMsgId))
this.messages = deduped.concat(this.messages)
this.messagePage = nextPage
this.hasMoreMessages = page ? (page.current || nextPage) < (page.pages || nextPage) : false
if (anchor) {
this.scrollAnimation = false
this.$nextTick(() => {
this.scrollIntoView = anchor
setTimeout(() => { this.scrollAnimation = true }, 120)
})
}
} finally {
this.loadingMessages = false
}
},
scrollToBottom(animated = true) {
this.$nextTick(() => {
const last = this.messages[this.messages.length - 1]
if (!last) return
this.scrollAnimation = animated
this.scrollIntoView = last.domId
})
},
sendPresence(text) {
if (!this.roomId) return
const data = { roomId: Number(this.roomId), eventType: 'presence', eventText: text }
ieSocket.sendPresence(data)
sendIePresence(this.roomId, data)
this.messages.push({ domId: 'presence-' + Date.now(), content: text, mine: true, clientMsgId: 'p-' + Date.now() })
this.scrollToBottom()
},
toggleEmoji() {
this.voiceMode = false
this.showEmoji = !this.showEmoji
},
sendEmoji(emoji) {
this.showEmoji = false
this.sendChatContent(emoji, 3)
},
sendMessage() {
if (!this.draft.trim()) {
@ -162,32 +353,310 @@
})
return
}
const content = this.draft.trim()
this.draft = ''
uni.showToast({
title: '已轻轻送出',
icon: 'none'
this.showEmoji = false
this.sendChatContent(content, 1)
},
sendChatContent(content, messageType, media = {}) {
if (!this.roomId) {
uni.showToast({ title: '房间信息缺失', icon: 'none' })
return
}
if (this.needWaitReply()) {
uni.showToast({ title: '先等等对方回复吧,首次破冰最多发送 3 条', icon: 'none' })
return
}
const clientMsgId = 'm-' + Date.now()
this.messages.push({
domId: 'msg-' + clientMsgId,
clientMsgId,
content,
messageType,
mediaDuration: media.mediaDuration,
mediaSize: media.mediaSize,
mediaFormat: media.mediaFormat,
mine: true,
pending: true
})
this.scrollToBottom()
this.persistMessage({
roomId: Number(this.roomId),
clientMsgId,
messageType,
content,
mediaDuration: media.mediaDuration,
mediaSize: media.mediaSize,
mediaFormat: media.mediaFormat
})
},
report() {
this.showSafety = false
uni.showToast({
title: '已收到举报',
icon: 'none'
needWaitReply() {
const delivered = this.messages.filter(item => item && !item.blocked && item.messageType)
if (delivered.some(item => !item.mine)) return false
return delivered.filter(item => item.mine).length >= 3
},
async persistMessage(payload) {
try {
const ack = await sendIeMessage(this.roomId, payload)
if (ack) {
this.applyAck(ack)
return
}
const msg = this.messages.find(item => item.clientMsgId === payload.clientMsgId)
if (msg) {
msg.pending = false
msg.blocked = true
msg.content = '发送失败,请稍后再试'
}
} catch (e) {
const msg = this.messages.find(item => item.clientMsgId === payload.clientMsgId)
if (msg) {
msg.pending = false
msg.blocked = true
msg.content = (e && e.message) || '发送失败,请稍后再试'
}
}
},
applyAck(ack) {
const msg = this.messages.find(item => item.clientMsgId === ack.clientMsgId)
if (!msg) return
msg.pending = false
msg.messageId = ack.messageId
msg.messageType = ack.messageType || msg.messageType || 1
msg.mediaDuration = ack.mediaDuration || msg.mediaDuration
msg.mediaSize = ack.mediaSize || msg.mediaSize
msg.mediaFormat = ack.mediaFormat || msg.mediaFormat
msg.blocked = ack.isBlocked === 1
msg.content = msg.blocked ? '这句话没有送出,请换一种更温柔的说法。' : (ack.content || msg.content)
},
chooseImage() {
if (this.uploadingImage) return
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: async (res) => {
const filePath = res.tempFilePaths && res.tempFilePaths[0]
if (!filePath) return
await this.uploadAndSendImage(filePath)
}
})
},
block() {
this.showSafety = false
uni.showToast({
title: '已拉黑',
icon: 'none'
async uploadAndSendImage(filePath) {
this.uploadingImage = true
try {
const result = await tui.uploadFile('/upload/file', filePath)
const imageUrl = typeof result === 'string' ? result : (result.url || result.fileUrl || result.path || result.fullPath || result)
if (!imageUrl || typeof imageUrl !== 'string') {
uni.showToast({ title: '图片上传失败', icon: 'none' })
return
}
this.showEmoji = false
this.sendChatContent(imageUrl, 2)
} finally {
this.uploadingImage = false
}
},
previewImage(url) {
if (!url) return
uni.previewImage({ urls: [url], current: url })
},
initRecorder() {
if (!uni.getRecorderManager) return
this.recorderManager = uni.getRecorderManager()
this.recorderManager.onStop(async (res) => {
const cancelled = this.voiceCancelling
this.recording = false
this.voiceCancelling = false
this.recordStartY = 0
if (cancelled) {
uni.showToast({ title: '已取消发送', icon: 'none' })
return
}
if (!res.tempFilePath) return
const duration = Math.max(1, Math.round((res.duration || (Date.now() - this.recordStartTime)) / 1000))
if (duration < 1) {
uni.showToast({ title: '说得太短啦', icon: 'none' })
return
}
await this.uploadAndSendVoice(res.tempFilePath, duration, res.fileSize)
})
this.recorderManager.onError(() => {
this.recording = false
uni.showToast({ title: '录音失败,请检查授权', icon: 'none' })
})
},
finishRoom() {
initAudio() {
if (!uni.createInnerAudioContext) return
this.innerAudioContext = uni.createInnerAudioContext()
this.innerAudioContext.onEnded(() => { this.playingVoiceUrl = '' })
this.innerAudioContext.onStop(() => { this.playingVoiceUrl = '' })
this.innerAudioContext.onError(() => {
if (this.voiceSwitching) return
this.playingVoiceUrl = ''
uni.showToast({ title: '语音播放失败', icon: 'none' })
})
},
recordTouchPoint(event) {
const touch = (event.touches && event.touches[0]) || (event.changedTouches && event.changedTouches[0])
return touch ? touch.clientY || touch.pageY || 0 : 0
},
startRecord(event) {
if (!this.recorderManager || this.uploadingVoice) return
this.showEmoji = false
this.recording = true
this.voiceCancelling = false
this.recordStartY = this.recordTouchPoint(event)
this.recordStartTime = Date.now()
this.recorderManager.start({
duration: 60000,
sampleRate: 16000,
numberOfChannels: 1,
encodeBitRate: 48000,
format: 'mp3'
})
},
moveRecord(event) {
if (!this.recording) return
const y = this.recordTouchPoint(event)
if (!y || !this.recordStartY) return
this.voiceCancelling = this.recordStartY - y > 70
},
stopRecord() {
if (!this.recorderManager || !this.recording) return
this.recorderManager.stop()
},
cancelRecord() {
if (!this.recorderManager || !this.recording) return
this.voiceCancelling = true
this.recording = false
this.recorderManager.stop()
},
async uploadAndSendVoice(filePath, duration, fileSize) {
if (!filePath || this.uploadingVoice) return
this.uploadingVoice = true
try {
const result = await tui.uploadFile('/upload/file', filePath)
const voiceUrl = typeof result === 'string' ? result : (result.url || result.fileUrl || result.path || result.fullPath || result)
if (!voiceUrl || typeof voiceUrl !== 'string') {
uni.showToast({ title: '语音上传失败', icon: 'none' })
return
}
this.sendChatContent(voiceUrl, 4, {
mediaDuration: Math.min(duration, 60),
mediaSize: fileSize,
mediaFormat: 'mp3'
})
} finally {
this.uploadingVoice = false
}
},
playVoice(item) {
if (!item || !item.content || !this.innerAudioContext) return
if (this.playingVoiceUrl === item.content) {
this.voiceSwitching = true
this.innerAudioContext.stop()
this.playingVoiceUrl = ''
setTimeout(() => { this.voiceSwitching = false }, 200)
return
}
this.voiceSwitching = true
this.innerAudioContext.stop()
setTimeout(() => {
this.innerAudioContext.src = item.content
this.playingVoiceUrl = item.content
this.voiceSwitching = false
this.innerAudioContext.play()
}, 80)
},
async openTargetProfile() {
await this.loadTargetProfile()
if (!this.targetProfile || !this.targetProfile.userId) {
this.targetProfile = {
anonymousName: this.companion.name,
avatarText: this.companion.avatar,
avatarUrl: this.companion.avatarUrl,
intro: this.companion.prompt,
currentMode: this.roomMode,
interestTags: []
}
}
this.showTargetProfile = true
},
async loadTargetProfile() {
if (!this.targetUserId) return
const profile = await getIeUserProfile(this.targetUserId)
if (!profile) return
this.targetProfile = profile
this.companions[this.roomMode] = {
...this.companion,
name: profile.anonymousName || this.companion.name,
avatar: profile.avatarText || this.companion.avatar,
avatarUrl: profile.avatarUrl || '',
prompt: profile.intro || this.companion.prompt
}
},
async report() {
this.showSafety = false
await reportIeRoom(this.roomId, { reportedUserId: this.targetUserId, reasonType: 'other', reasonText: '聊天内容不适' })
uni.showToast({ title: '已收到举报', icon: 'none' })
},
async block() {
this.showSafety = false
if (this.targetUserId) await blockIeUser(this.targetUserId, '聊天中拉黑')
uni.showToast({ title: '已拉黑', icon: 'none' })
},
async finishRoom() {
if (this.finishing) return
this.finishing = true
this.clearTimer()
if (this.roomId) await finishIeRoom(this.roomId)
uni.redirectTo({
url: '/package1/ieBrowser/chatList'
})
},
initSocket() {
if (!this.roomId) return
ieSocket.resetHandlers()
ieSocket.onAck((ack) => {
this.applyAck(ack)
})
ieSocket.onMessage((msg) => {
if (String(msg.roomId) !== String(this.roomId)) return
if (this.messages.some(item => String(item.messageId) === String(msg.messageId))) return
if (msg.clientMsgId && this.messages.some(item => item.clientMsgId === msg.clientMsgId)) {
this.applyAck(msg)
return
}
this.messages.push({
domId: 'msg-' + (msg.messageId || Date.now()),
messageId: msg.messageId,
clientMsgId: msg.clientMsgId,
messageType: msg.messageType || 1,
mediaDuration: msg.mediaDuration,
mediaSize: msg.mediaSize,
mediaFormat: msg.mediaFormat,
content: msg.content,
mine: String(msg.senderId || '') === String(uni.getStorageSync('id') || '')
})
this.scrollToBottom()
})
ieSocket.onPresence((event) => {
if (String(event.roomId) !== String(this.roomId)) return
this.typingHint = event.eventText || '对方有一个轻轻回应'
setTimeout(() => { this.typingHint = '' }, 1800)
})
ieSocket.onRoomEnd((room) => {
if (String(room.id) !== String(this.roomId)) return
uni.showToast({ title: '这段聊天已结束', icon: 'none' })
this.clearTimer()
this.finishing = true
uni.redirectTo({ url: '/package1/ieBrowser/chatList' })
})
ieSocket.connect().then(() => {
ieSocket.subscribeRoom(this.roomId)
})
},
back() {
uni.redirectTo({ url: '/package1/ieBrowser/index' })
}
@ -267,9 +736,32 @@
font-size: 34rpx;
}
.nav-avatar-wrap {
width: 66rpx;
height: 66rpx;
margin-right: 14rpx;
border-radius: 50%;
}
.nav-avatar,
.nav-avatar-img {
width: 66rpx;
height: 66rpx;
border-radius: 50%;
}
.nav-avatar {
text-align: center;
line-height: 66rpx;
color: #11162a;
background: linear-gradient(145deg, #ffffff, #a9ffe7);
font-size: 26rpx;
font-weight: 800;
}
.room-user {
flex: 1;
text-align: center;
text-align: left;
}
.room-name {
@ -420,7 +912,19 @@
.messages {
position: relative;
z-index: 1;
height: 58vh;
padding-top: 34rpx;
padding-bottom: 24rpx;
box-sizing: border-box;
}
.load-more {
width: 360rpx;
margin: 0 auto 24rpx;
text-align: center;
color: rgba(22, 27, 46, 0.34);
font-size: 21rpx;
line-height: 34rpx;
}
.system-line {
@ -457,11 +961,15 @@
justify-content: flex-end;
}
.avatar {
.avatar,
.avatar-img {
width: 72rpx;
height: 72rpx;
margin-right: 16rpx;
border-radius: 50%;
}
.avatar {
text-align: center;
line-height: 72rpx;
font-size: 27rpx;
@ -498,6 +1006,44 @@
background: rgba(223, 254, 244, 0.88);
}
.image-bubble {
padding: 8rpx;
background: rgba(255, 255, 255, 0.72);
}
.chat-image {
display: block;
width: 260rpx;
height: 260rpx;
border-radius: 24rpx;
background: rgba(22, 27, 46, 0.06);
}
.emoji-bubble {
min-width: 72rpx;
text-align: center;
font-size: 48rpx;
line-height: 62rpx;
}
.voice-bubble {
min-width: 180rpx;
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 800;
}
.voice-bubble.playing {
box-shadow: 0 0 0 4rpx rgba(169, 255, 231, 0.32);
}
.voice-wave {
margin-right: 24rpx;
color: #6c69d8;
letter-spacing: 4rpx;
}
.presence-row {
position: fixed;
left: 28rpx;
@ -539,6 +1085,31 @@
align-items: center;
}
.emoji-panel {
position: fixed;
left: 24rpx;
right: 24rpx;
bottom: 128rpx;
z-index: 11;
display: flex;
flex-wrap: wrap;
padding: 22rpx 20rpx 12rpx;
border: 1rpx solid rgba(255, 255, 255, 0.78);
border-radius: 34rpx;
background: rgba(255, 255, 255, 0.88);
backdrop-filter: blur(20rpx);
box-shadow: 0 18rpx 58rpx rgba(96, 112, 160, 0.16);
box-sizing: border-box;
}
.emoji-item {
width: 16.66%;
height: 66rpx;
text-align: center;
line-height: 66rpx;
font-size: 42rpx;
}
.input {
flex: 1;
height: 66rpx;
@ -549,6 +1120,41 @@
font-size: 25rpx;
}
.voice-hold {
flex: 1;
height: 62rpx;
line-height: 62rpx;
padding: 0 18rpx;
border-radius: 999rpx;
text-align: center;
color: rgba(22, 27, 46, 0.62);
background: rgba(238, 244, 255, 0.86);
font-size: 25rpx;
font-weight: 800;
}
.voice-hold.recording {
color: #09101f;
background: #ffe4ec;
}
.voice-hold.cancelling {
color: #fff;
background: #ff6b8a;
}
.tool-btn {
width: 62rpx;
height: 62rpx;
border-radius: 50%;
text-align: center;
line-height: 62rpx;
color: rgba(22, 27, 46, 0.58);
background: rgba(238, 244, 255, 0.72);
font-size: 34rpx;
font-weight: 800;
}
.send {
height: 66rpx;
line-height: 66rpx;
@ -602,6 +1208,97 @@
color: #6c69d8;
}
.profile-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 32;
display: flex;
align-items: flex-end;
padding: 34rpx;
background: rgba(22, 27, 46, 0.16);
backdrop-filter: blur(16rpx);
box-sizing: border-box;
}
.profile-panel {
width: 100%;
padding: 42rpx 34rpx 34rpx;
border-radius: 44rpx;
background: rgba(255, 255, 255, 0.88);
border: 1rpx solid rgba(255, 255, 255, 0.92);
box-shadow: 0 30rpx 90rpx rgba(96, 112, 160, 0.18);
text-align: center;
box-sizing: border-box;
}
.profile-avatar,
.profile-avatar-img {
width: 132rpx;
height: 132rpx;
margin: 0 auto;
border-radius: 50%;
}
.profile-avatar {
line-height: 132rpx;
color: #11162a;
background: linear-gradient(145deg, #ffffff, #a9ffe7);
font-size: 44rpx;
font-weight: 800;
}
.profile-name {
margin-top: 24rpx;
font-size: 36rpx;
font-weight: 800;
}
.profile-mode {
margin-top: 10rpx;
color: rgba(22, 27, 46, 0.48);
font-size: 23rpx;
}
.profile-intro {
margin-top: 26rpx;
padding: 24rpx;
border-radius: 28rpx;
color: rgba(22, 27, 46, 0.68);
background: rgba(238, 244, 255, 0.72);
font-size: 25rpx;
line-height: 40rpx;
}
.profile-tags {
margin-top: 22rpx;
}
.profile-tags text {
display: inline-block;
height: 44rpx;
line-height: 44rpx;
padding: 0 16rpx;
margin: 0 10rpx 10rpx 0;
border-radius: 999rpx;
color: #6c69d8;
background: rgba(139, 124, 255, 0.1);
font-size: 21rpx;
}
.profile-close {
height: 82rpx;
line-height: 82rpx;
margin-top: 24rpx;
border-radius: 999rpx;
color: #11162a;
background: #a9ffe7;
font-size: 26rpx;
font-weight: 800;
}
@keyframes noiseFloat {
0%, 100% { transform: translate3d(0,0,0); }
50% { transform: translate3d(12rpx,-8rpx,0); }

147
package1/ieBrowser/chatList.vue

@ -9,9 +9,9 @@
<view class="summary-card">
<view>
<view class="summary-label">Tonight Archive</view>
<view class="summary-title">今晚被轻轻接住了 2 </view>
<view class="summary-sub">这里不保存完整聊天只留下当时的情绪温度</view>
<view class="summary-label">Companion Chat</view>
<view class="summary-title">已匹配的人可以继续聊</view>
<view class="summary-sub">每天 3 次是新的随机匹配机会已经匹配成功的聊天会保留在这里</view>
</view>
<view class="moon"></view>
</view>
@ -26,13 +26,15 @@
</view>
</view>
<view class="record-card" v-for="item in records" :key="item.time">
<view class="record-card" v-for="item in records" :key="item.id" @tap="openChat(item)">
<view class="record-top">
<view class="record-orb" :class="item.type">{{ item.type }}</view>
<image class="record-orb-img" v-if="item.avatarUrl" :src="item.avatarUrl" mode="aspectFill"></image>
<view class="record-orb" v-else :class="item.type">{{ item.avatar }}</view>
<view>
<view class="record-name">{{ item.name }}</view>
<view class="record-meta">{{ item.time }} · {{ item.duration }}</view>
</view>
<view class="unread-dot" v-if="item.unread">未读</view>
</view>
<view class="record-text">{{ item.feeling }}</view>
<view class="record-tags">
@ -40,13 +42,14 @@
</view>
</view>
<view class="empty-note">
不是消息列表也不是社交关系只是给夜晚留一点柔软的痕迹
</view>
<view class="empty-note" v-if="!loading && !records.length">还没有聊天记录匹配成功后对方会出现在这里可以继续对话</view>
<view class="empty-note" v-else>{{ loading ? '正在加载...' : (hasMore ? '上滑加载更多' : '没有更多记录了') }}</view>
</view>
</template>
<script>
import { pageIeRecords, getIeUserProfile } from '@/common/ieApi.js'
export default {
data() {
return {
@ -55,36 +58,103 @@
},
currentFeeling: '轻了一点',
feelings: ['轻了一点', '还是安静', '被听见了', '想睡了'],
records: [{
type: 'i',
name: '月台旁的影子',
time: '23:18',
duration: '15 分钟',
feeling: '对方没有催我讲话,沉默变得没那么尴尬。',
tags: ['安静陪伴', '想安静', '半匿名']
}, {
type: 'e',
name: '便利店灯光',
time: '21:42',
duration: '12 分钟',
feeling: '聊了一个很小的夜宵话题,心情被拉亮了一点。',
tags: ['轻轻热闹', '想说话', '限时']
}, {
type: 'i',
name: '耳机里的风',
time: '昨天',
duration: '15 分钟',
feeling: '像有人坐在旁边,不需要解释为什么低落。',
tags: ['慢回复', '听着呢', '低压力']
}]
records: [],
pageNumber: 1,
pageSize: 10,
hasMore: true,
loading: false
}
},
onLoad() {
if (uni.getMenuButtonBoundingClientRect) {
this.menuButtonInfo = uni.getMenuButtonBoundingClientRect()
}
this.refreshRecords()
},
onReachBottom() {
this.loadRecords()
},
methods: {
formatTime(value) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
const now = new Date()
const sameDay = date.toDateString() === now.toDateString()
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1).toDateString()
if (sameDay) return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
if (date.toDateString() === yesterday) return '昨天'
return `${date.getMonth() + 1}/${date.getDate()}`
},
formatDuration(seconds) {
const minutes = Math.max(1, Math.round((seconds || 0) / 60))
return `${minutes} 分钟`
},
parseTags(tags) {
if (!tags) return ['半匿名']
if (Array.isArray(tags)) return tags
return String(tags).split(/[,,]/).filter(Boolean)
},
normalizeRecord(item) {
return {
id: item.id,
roomId: item.roomId,
targetUserId: item.targetUserId,
type: item.mode || 'i',
name: item.anonymousName || '半匿名漂流者',
avatar: item.avatarText || (item.mode === 'e' ? '光' : '月'),
avatarUrl: item.avatarUrl || '',
time: this.formatTime(item.createTime),
duration: this.formatDuration(item.durationSeconds),
feeling: item.feeling || item.summary || '你们已经匹配成功,可以继续这段轻陪伴。',
tags: this.parseTags(item.tags),
unread: item.unreadCount > 0 || item.hasUnread === true
}
},
openChat(item) {
if (!item || !item.roomId) return
uni.navigateTo({
url: '/package1/ieBrowser/chat?mode=' + item.type +
'&roomId=' + item.roomId +
'&targetUserId=' + (item.targetUserId || '') +
'&name=' + encodeURIComponent(item.name || '半匿名漂流者') +
'&avatar=' + encodeURIComponent(item.type || '◌') +
'&quote=' + encodeURIComponent(item.feeling || '')
})
},
async refreshRecords() {
this.pageNumber = 1
this.hasMore = true
this.records = []
await this.loadRecords()
},
async loadRecords() {
if (this.loading || !this.hasMore) return
this.loading = true
try {
const page = await pageIeRecords(this.pageNumber, this.pageSize)
const list = page && page.records ? page.records.map(this.normalizeRecord) : []
await this.fillTargetProfiles(list)
const exists = new Set(this.records.map(item => item.id))
this.records = this.records.concat(list.filter(item => !exists.has(item.id)))
this.hasMore = page ? (page.current || this.pageNumber) < (page.pages || this.pageNumber) : false
this.pageNumber += 1
} finally {
this.loading = false
}
},
async fillTargetProfiles(list) {
const tasks = list
.filter(item => item.targetUserId)
.map(async (item) => {
const profile = await getIeUserProfile(item.targetUserId)
if (!profile) return
item.name = profile.anonymousName || item.name
item.avatar = profile.avatarText || item.avatar
item.avatarUrl = profile.avatarUrl || ''
})
await Promise.all(tasks)
},
back() {
uni.redirectTo({ url: '/package1/ieBrowser/index' })
}
@ -259,6 +329,13 @@
font-weight: 800;
}
.record-orb-img {
width: 78rpx;
height: 78rpx;
margin-right: 18rpx;
border-radius: 50%;
}
.record-orb.i {
color: #f7f4ff;
background: linear-gradient(145deg, #8b7cff, #dde7ff);
@ -280,6 +357,16 @@
font-size: 22rpx;
}
.unread-dot {
margin-left: auto;
padding: 8rpx 14rpx;
border-radius: 999rpx;
color: #fff;
background: #ff6b8a;
font-size: 20rpx;
font-weight: 800;
}
.record-text {
margin-top: 22rpx;
color: rgba(22, 27, 46, 0.72);

34
package1/ieBrowser/companion.vue

@ -8,14 +8,15 @@
</view>
<view class="label">半匿名陪伴对象</view>
<view class="companion-card">
<view class="orb" :class="mode">{{ companion.avatar }}</view>
<image class="orb-img" v-if="companion.avatarUrl" :src="companion.avatarUrl" mode="aspectFill"></image>
<view class="orb" v-else :class="mode">{{ companion.avatar }}</view>
<view class="name">{{ companion.name }}</view>
<view class="state">{{ companion.state }}</view>
<view class="quote">{{ companion.quote }}</view>
<view class="tags">
<text>{{ modeText }}</text>
<text>限时 15 分钟</text>
<text>不留完整记录</text>
<text>可继续聊天</text>
<text>保留聊天记录</text>
</view>
</view>
<view class="actions">
@ -30,6 +31,8 @@
data() {
return {
mode: 'i',
roomId: '',
targetUserId: '',
menuButtonInfo: { top: 44 },
pool: {
i: { name: '月台旁的影子', avatar: '月', state: '在图书馆发呆', quote: '可以安静待 15 分钟,不用急着找话题。' },
@ -44,9 +47,29 @@
onLoad(options) {
if (uni.getMenuButtonBoundingClientRect) this.menuButtonInfo = uni.getMenuButtonBoundingClientRect()
this.mode = options.mode || 'i'
this.roomId = options.roomId || ''
this.targetUserId = options.targetUserId || ''
if (options.name) {
this.pool[this.mode] = {
name: decodeURIComponent(options.name),
avatar: decodeURIComponent(options.avatar || '◌'),
avatarUrl: decodeURIComponent(options.avatarUrl || ''),
state: decodeURIComponent(options.state || ''),
quote: decodeURIComponent(options.quote || '可以先安静待一会,不用急着找话题。')
}
}
},
methods: {
enter() { uni.redirectTo({ url: '/package1/ieBrowser/chat?mode=' + this.mode }) },
enter() {
const companion = this.companion
uni.redirectTo({
url: '/package1/ieBrowser/chat?mode=' + this.mode + '&roomId=' + this.roomId + '&targetUserId=' + this.targetUserId +
'&name=' + encodeURIComponent(companion.name || '') +
'&avatar=' + encodeURIComponent(companion.avatar || '') +
'&avatarUrl=' + encodeURIComponent(companion.avatarUrl || '') +
'&quote=' + encodeURIComponent(companion.quote || '')
})
},
skip() { uni.redirectTo({ url: '/package1/ieBrowser/index' }) },
back() { uni.redirectTo({ url: '/package1/ieBrowser/index' }) }
}
@ -61,7 +84,8 @@
.nav-title { flex: 1; text-align: center; font-size: 30rpx; font-weight: 800; }
.label { margin-top: 20rpx; color: rgba(22,27,46,.42); font-size: 23rpx; letter-spacing: 4rpx; text-transform: uppercase; }
.companion-card { margin-top: 54rpx; padding: 48rpx 36rpx; border-radius: 56rpx; border: 1rpx solid rgba(255,255,255,.82); background: rgba(255,255,255,.64); backdrop-filter: blur(28rpx); box-shadow: 0 30rpx 90rpx rgba(96,112,160,.16); text-align: center; animation: cardIn .58s ease both; }
.orb { width: 168rpx; height: 168rpx; margin: 0 auto; border-radius: 50%; text-align: center; line-height: 168rpx; font-size: 58rpx; font-weight: 800; }
.orb, .orb-img { width: 168rpx; height: 168rpx; margin: 0 auto; border-radius: 50%; }
.orb { text-align: center; line-height: 168rpx; font-size: 58rpx; font-weight: 800; }
.orb.i { color: #f7f4ff; background: linear-gradient(145deg, #8b7cff, #dde7ff); box-shadow: 0 22rpx 68rpx rgba(139,124,255,.25); }
.orb.e { color: #11162a; background: linear-gradient(145deg, #a9ffe7, #fff0b8); }
.name { margin-top: 30rpx; font-size: 42rpx; font-weight: 800; }

64
package1/ieBrowser/fate.vue

@ -7,12 +7,12 @@
<view class="ghost"></view>
</view>
<view class="hero">
<view class="hero-title">今天的 3 次轻连接</view>
<view class="hero-title">今天的 {{ todayCount }} 次轻连接</view>
<view class="hero-sub">它们不会变成关系压力只留下一点情绪轨迹</view>
</view>
<view class="timeline">
<view class="line"></view>
<view class="fate-card" v-for="item in fates" :key="item.time">
<view class="fate-card" v-for="item in fates" :key="item.id">
<view class="node" :class="item.type">{{ item.type }}</view>
<view class="card-main">
<view class="card-top">
@ -24,25 +24,74 @@
</view>
</view>
</view>
<view class="empty">{{ loading ? '正在加载...' : (hasMore ? '上滑加载更多缘分' : (fates.length ? '今天先到这里' : '今天还没有轻连接')) }}</view>
</view>
</template>
<script>
import { pageIeRecords } from '@/common/ieApi.js'
export default {
data() {
return {
menuButtonInfo: { top: 44 },
fates: [
{ type: 'i', name: '月台旁的影子', time: '23:18', feeling: '沉默不尴尬,像有人坐在旁边。', remeet: true },
{ type: 'e', name: '便利店灯光', time: '21:42', feeling: '聊了一句夜宵废话,心情被拉亮一点。', remeet: false },
{ type: 'i', name: '耳机里的风', time: '昨天', feeling: '不用解释低落,也被轻轻听见。', remeet: false }
]
fates: [],
pageNumber: 1,
pageSize: 10,
hasMore: true,
loading: false
}
},
computed: {
todayCount() {
const today = new Date().toDateString()
return this.fates.filter(item => item.rawDate && item.rawDate.toDateString() === today).length
}
},
onLoad() {
if (uni.getMenuButtonBoundingClientRect) this.menuButtonInfo = uni.getMenuButtonBoundingClientRect()
this.loadFates()
},
onReachBottom() {
this.loadFates()
},
methods: {
formatTime(value) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
const now = new Date()
if (date.toDateString() === now.toDateString()) {
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
}
return `${date.getMonth() + 1}/${date.getDate()}`
},
normalizeRecord(item) {
const date = new Date(item.createTime)
return {
id: item.id,
type: item.mode || 'i',
name: item.anonymousName || '半匿名漂流者',
time: this.formatTime(item.createTime),
rawDate: Number.isNaN(date.getTime()) ? null : date,
feeling: item.feeling || item.summary || '这次连接只留下了一点情绪轨迹。',
remeet: item.remeetAvailable === 1
}
},
async loadFates() {
if (this.loading || !this.hasMore) return
this.loading = true
try {
const page = await pageIeRecords(this.pageNumber, this.pageSize)
const list = page && page.records ? page.records.map(this.normalizeRecord) : []
const exists = new Set(this.fates.map(item => item.id))
this.fates = this.fates.concat(list.filter(item => !exists.has(item.id)))
this.hasMore = page ? (page.current || this.pageNumber) < (page.pages || this.pageNumber) : false
this.pageNumber += 1
} finally {
this.loading = false
}
},
back() { uni.redirectTo({ url: '/package1/ieBrowser/index' }) }
}
}
@ -69,4 +118,5 @@
.time { color: rgba(21,26,45,.4); font-size: 22rpx; }
.feeling { margin-top: 14rpx; color: rgba(21,26,45,.62); font-size: 25rpx; line-height: 40rpx; }
.remeet { display: inline-block; margin-top: 18rpx; padding: 8rpx 18rpx; border-radius: 999rpx; color: #11162a; background: #dffef4; font-size: 21rpx; }
.empty { margin-top: 32rpx; text-align: center; color: rgba(21,26,45,.36); font-size: 23rpx; }
</style>

10
package1/ieBrowser/friends.vue

@ -18,8 +18,8 @@
<text class="stat-label">今日机会</text>
</view>
<view>
<text class="stat-num">15m</text>
<text class="stat-label">默认限时</text>
<text class="stat-num">长期</text>
<text class="stat-label">聊天保留</text>
</view>
<view>
<text class="stat-num">0</text>
@ -70,8 +70,8 @@
title: '夜间更活跃',
desc: '22:00 后更需要轻陪伴。'
}, {
title: '记录不留全文',
desc: '只保留情绪,不保存完整聊天。'
title: '聊天可回访',
desc: '匹配成功后保留聊天入口和历史消息。'
}, {
title: '不开放主页',
desc: '没有访客、关注、粉丝和点赞。'
@ -90,7 +90,7 @@
'不做好友列表,做轻关系保护。',
'不做动态广场,做当下状态入口。',
'不做颜值筛选,做半匿名陪伴。',
'不做无限聊天,做限时共处。'
'不做无限刷匹配,已匹配的人可以继续聊。'
]
}
},

289
package1/ieBrowser/index.vue

@ -6,6 +6,14 @@
<view class="status-head">
<view class="time-line">{{ timeLabel }}</view>
<view class="profile-line" @tap="goUniverse">
<image class="profile-avatar-img" v-if="profile.avatarUrl" :src="profile.avatarUrl" mode="aspectFill"></image>
<view class="profile-avatar" v-else>{{ profile.avatarText || '' }}</view>
<view>
<view class="profile-name">{{ profile.anonymousName || '半匿名漂流者' }}</view>
<view class="profile-tags">{{ profileTagsText }}</view>
</view>
</view>
<view class="live-line">此刻有 <text class="count-num">{{ displayAwakeCount }}</text> 个灵魂在线漂流</view>
<view class="sub-line">{{ waitingCount }} 个人正在等一句话</view>
<view class="archive-entry planet-entry" @tap="openProfile">
@ -64,6 +72,24 @@
</scroll-view>
</view>
<view class="target-dock">
<view class="target-title">今天想遇见谁</view>
<view class="target-row">
<view class="target-chip" v-for="item in targetModes" :key="item.key"
:class="{ active: targetMode === item.key }" @tap="targetMode = item.key">
{{ item.label }}
</view>
</view>
<view class="target-title gender-title">想匹配的性别</view>
<view class="target-row">
<view class="target-chip gender-chip" v-for="item in targetGenders" :key="item.key"
:class="{ active: targetGender === item.key }" @tap="targetGender = item.key">
{{ item.label }}
</view>
</view>
<view class="start-match-btn" @tap="openMatch">开始随机陪伴</view>
</view>
<view class="bottom-actions">
<view class="tab-glow"></view>
<view class="bottom-item active"><text class="tab-icon"></text><text>此刻</text></view>
@ -78,13 +104,14 @@
<view class="match-label">半匿名漂流者</view>
<view class="close" @tap="closeMatch">×</view>
</view>
<view class="companion-orb" :class="currentMode">{{ matchedPerson.avatar }}</view>
<image class="companion-orb-img" v-if="matchedPerson.avatarUrl" :src="matchedPerson.avatarUrl" mode="aspectFill"></image>
<view class="companion-orb" v-else :class="currentMode">{{ matchedPerson.avatar }}</view>
<view class="match-name">{{ matchedPerson.name }}</view>
<view class="match-state">{{ activeMood.label }} · {{ matchedPerson.state }}</view>
<view class="match-quote">{{ matchedPerson.quote }}</view>
<view class="match-actions">
<view class="ghost-btn" @tap="skipMatch">轻轻划过</view>
<view class="solid-btn" @tap="goChat">进入限时陪伴</view>
<view class="solid-btn" @tap="goChat">进入聊天</view>
</view>
</view>
</view>
@ -101,6 +128,8 @@
</template>
<script>
import { ieHome, updateIeStatus, startIeMatch } from '@/common/ieApi.js'
export default {
data() {
return {
@ -116,6 +145,10 @@
liveTimer: null,
countTimer: null,
showProfile: false,
profileReady: false,
profile: {},
targetMode: 'any',
targetGender: 'any',
activeDriftText: '',
displayMoodCopy: '对方没有催我讲话,沉默变得没那么尴尬。',
moodTypeTimer: null,
@ -125,6 +158,7 @@
galaxyMoved: false,
galaxyStartX: 0,
galaxyStartY: 0,
galaxyLastMoveTime: 0,
galaxyAutoAngle: 0,
galaxyTimer: null,
orbitPlanets: [
@ -138,8 +172,11 @@
matchText: "随机遇见一个人",
doneText: "今天先到这里",
moods: [{"key":"quiet","label":"想安静","icon":"◐","copy":"对方没有催我讲话,沉默变得没那么尴尬。"},{"key":"talk","label":"想说话","icon":"◒","copy":"聊了一个很小的日常话题,心情被拉亮了一点。"},{"key":"listen","label":"听着呢","icon":"◌","copy":"像有人坐在旁边,不需要解释为什么低落。"},{"key":"drift","label":"轻了一点","icon":"✦","copy":"只是给今天留一点柔软的痕迹。"}],
targetModes: [{ key: 'i', label: '匹配 i 人' }, { key: 'e', label: '匹配 e 人' }, { key: 'any', label: '都可以' }],
targetGenders: [{ key: 'male', label: '男生' }, { key: 'female', label: '女生' }, { key: 'any', label: '不限' }],
companions: {"i":[{"name":"树荫下的风","avatar":"风","state":"在校园里发呆","quote":"可以安静待 15 分钟,不用急着找话题。"},{"name":"耳机里的云","avatar":"云","state":"刚从教室出来","quote":"今天只想慢慢说两句。"}],"e":[{"name":"便利店灯光","avatar":"光","state":"想聊点不重要的","quote":"要不要交换一句今天最荒唐的小事?"},{"name":"操场散步员","avatar":"跑","state":"刚从操场回来","quote":"我可以负责开场,你负责随便接。"}]},
matchedPerson: {},
currentMatch: null,
stateBubbles: [
{ type: 'i', text: '有人刚下课,想安静走一会。', time: '2 分钟前' },
{ type: 'e', text: '一个 e 人想聊 5 分钟废话。', time: '刚刚' },
@ -151,20 +188,24 @@
{ icon: '◌', text: '只想旁听', sub: '听别人说说', mood: 'listen' }
],
driftMessages: [
{ type: 'i', mood: 'quiet', text: '操现在风很舒服' },
{ type: 'i', mood: 'quiet', text: '操现在风很舒服' },
{ type: 'i', mood: 'listen', text: '有人在图书馆假装努力' },
{ type: 'e', mood: 'talk', text: '谁愿意陪我散步 10 分钟' },
{ type: 'i', mood: 'quiet', text: '刚刚有人有点低落' },
{ type: 'e', mood: 'talk', text: '想听一个不重要的故事' },
{ type: 'i', mood: 'drift', text: '走在路上突然有点空' }
],
rules: [{"title":"半匿名","desc":"不展示学校、真实照片和复杂资料。"},{"title":"限时","desc":"房间自然结束,关系不会变成负担。"},{"title":"低压力","desc":"可以聊天,也可以只发一个“我在”。"}]
rules: [{"title":"半匿名","desc":"不展示学校、真实照片和复杂资料。"},{"title":"可回访","desc":"匹配成功后会保留聊天入口,可以继续对话。"},{"title":"低压力","desc":"可以聊天,也可以只发一个“我在”。"}]
}
},
computed: {
activeMood() {
return this.moods.find(item => item.key === this.currentMood) || this.moods[0]
},
profileTagsText() {
const tags = this.profile.interestTags || []
return tags.length ? tags.slice(0, 3).join(' · ') : (this.currentMode === 'i' ? '安静陪伴' : '轻轻热闹')
},
timeLabel() {
const hours = String(this.now.getHours()).padStart(2, '0')
const minutes = String(this.now.getMinutes()).padStart(2, '0')
@ -191,7 +232,6 @@
`color: ${planet.color}`,
`opacity: ${opacity}`,
`z-index: ${zIndex}`,
`filter: blur(${blur}rpx)`,
`transform: translate(-50%, -50%) translate(${x}rpx, ${y}rpx) scale(${scale})`
].join(';')
})
@ -202,19 +242,46 @@
this.menuButtonInfo = uni.getMenuButtonBoundingClientRect()
}
this.pickCompanion()
this.loadHome()
this.animateAwakeCount(this.awakeCount)
this.startPageTimers()
this.displayMoodCopy = this.activeMood.copy
},
onShow() {
this.startPageTimers()
uni.authorize({
scope: 'scope.record',
success() {
},
fail() {
this.tui.toast("您未授权,语音功能可能会出现错误")
}
})
},
onHide() {
this.stopPageTimers()
},
onUnload() {
this.stopPageTimers()
},
methods: {
startPageTimers() {
if (!this.liveTimer) {
this.liveTimer = setInterval(() => {
this.now = new Date()
this.awakeCount = 118 + Math.floor(Math.random() * 28)
this.waitingCount = 39 + Math.floor(Math.random() * 18)
this.animateAwakeCount(this.awakeCount)
}, 2600)
}, 4200)
}
if (!this.galaxyTimer) {
this.galaxyTimer = setInterval(() => {
this.galaxyAutoAngle = (this.galaxyAutoAngle + 1.2) % 360
}, 50)
this.displayMoodCopy = this.activeMood.copy
this.galaxyAutoAngle = (this.galaxyAutoAngle + 3.8) % 360
}, 160)
}
},
onUnload() {
stopPageTimers() {
if (this.liveTimer) {
clearInterval(this.liveTimer)
this.liveTimer = null
@ -232,7 +299,6 @@
this.moodTypeTimer = null
}
},
methods: {
animateAwakeCount(target) {
if (this.countTimer) {
clearInterval(this.countTimer)
@ -242,39 +308,97 @@
let step = 0
this.countTimer = setInterval(() => {
step += 1
const progress = Math.min(step / 18, 1)
const progress = Math.min(step / 10, 1)
const eased = 1 - Math.pow(1 - progress, 3)
this.displayAwakeCount = Math.round(start + diff * eased)
if (progress >= 1) {
clearInterval(this.countTimer)
this.countTimer = null
}
}, 24)
}, 42)
},
pickCompanion() {
const list = this.companions[this.currentMode]
this.matchedPerson = list[Math.floor(Math.random() * list.length)]
},
openMatch() {
async loadHome() {
const home = await ieHome()
if (!home) return
if (home.profileCompleted !== 1) {
uni.redirectTo({ url: '/package1/ieBrowser/profileSetup' })
return
}
this.profile = home.profile || {}
this.profileReady = true
this.awakeCount = home.onlineCount || this.awakeCount
this.waitingCount = home.waitingCount || this.waitingCount
this.chancesLeft = Math.max((home.dailyQuota || 3) - (home.usedQuota || 0), 0)
this.currentMode = home.currentMode || this.currentMode
this.currentMood = home.currentMood || this.currentMood
this.targetMode = home.targetModePreference || this.targetMode
this.targetGender = home.targetGenderPreference || this.targetGender
if (home.hotStatuses && home.hotStatuses.length) {
this.driftMessages = home.hotStatuses.slice(0, 6).map((text, index) => ({
type: index % 2 === 0 ? 'i' : 'e',
mood: this.moods[index % this.moods.length].key,
text
}))
}
this.animateAwakeCount(this.awakeCount)
this.syncStatus()
},
syncStatus() {
if (!this.profileReady) return
updateIeStatus({
mode: this.currentMode,
targetMode: this.targetMode,
targetGender: this.targetGender,
mood: this.currentMood,
statusText: this.activeMood.copy,
interestTags: [this.activeMood.label]
})
},
async openMatch() {
if (this.chancesLeft === 0) {
uni.showToast({ title: this.doneText, icon: 'none' })
return
}
this.pickCompanion()
const match = await startIeMatch({
mode: this.currentMode,
targetMode: this.targetMode,
targetGender: this.targetGender,
mood: this.currentMood,
interestTags: [this.activeMood.label]
})
if (!match || !match.roomId) {
uni.showToast({ title: (match && match.failReason) || '暂时没有同频的人', icon: 'none' })
return
}
this.currentMatch = match
this.matchedPerson = {
avatar: match.avatarText || '◌',
avatarUrl: match.avatarUrl || '',
name: match.anonymousName || '半匿名漂流者',
state: match.stateText || '也在等一句轻轻的回应',
quote: match.quoteText || '可以先安静待一会,不用急着找话题。'
}
this.showMatch = true
},
closeMatch() { this.showMatch = false },
toggleMode() {
this.currentMode = this.currentMode === 'i' ? 'e' : 'i'
this.syncStatus()
},
changeMood(key) {
this.currentMood = key
this.playMoodCopy(this.activeMood.copy)
this.syncStatus()
},
selectDrift(item) {
this.currentMood = item.mood
this.currentMode = item.type
this.playMoodCopy(this.activeMood.copy)
this.syncStatus()
this.activeDriftText = this.activeDriftText === item.text ? '' : item.text
},
playMoodCopy(text) {
@ -301,7 +425,7 @@
uni.showToast({ title: '已轻轻回应', icon: 'none' })
},
openProfile() {
this.openMatch()
this.goUniverse()
},
closeProfile() {
this.showProfile = false
@ -324,6 +448,9 @@
},
moveGalaxyDrag(event) {
if (!this.galaxyDragging) return
const now = Date.now()
if (now - this.galaxyLastMoveTime < 32) return
this.galaxyLastMoveTime = now
const point = this.getGalaxyPoint(event)
if (!point) return
const deltaX = point.x - this.galaxyStartX
@ -351,10 +478,18 @@
goChat() {
this.chancesLeft = Math.max(this.chancesLeft - 1, 0)
this.showMatch = false
uni.navigateTo({ url: '/package1/ieBrowser/chat?mode=' + this.currentMode + '&mood=' + this.currentMood })
const room = this.currentMatch ? '&roomId=' + this.currentMatch.roomId + '&targetUserId=' + this.currentMatch.targetUserId +
'&name=' + encodeURIComponent(this.matchedPerson.name || '') +
'&avatar=' + encodeURIComponent(this.matchedPerson.avatar || '') +
'&avatarUrl=' + encodeURIComponent(this.matchedPerson.avatarUrl || '') +
'&quote=' + encodeURIComponent(this.matchedPerson.quote || '') : ''
uni.navigateTo({ url: '/package1/ieBrowser/chat?mode=' + this.currentMode + '&mood=' + this.currentMood + room })
},
async goMatch() {
await this.openMatch()
},
goMatch() {
uni.navigateTo({ url: '/package1/ieBrowser/match?mode=' + this.currentMode + '&mood=' + this.currentMood })
goMatchPage() {
uni.navigateTo({ url: '/package1/ieBrowser/match?mode=' + this.currentMode + '&mood=' + this.currentMood + '&targetMode=' + this.targetMode + '&targetGender=' + this.targetGender })
},
goRecords() { uni.navigateTo({ url: '/package1/ieBrowser/chatList' }) },
goArchive() { uni.navigateTo({ url: '/package1/ieBrowser/universe' }) },
@ -803,11 +938,15 @@
line-height: 34rpx;
}
.companion-orb {
.companion-orb,
.companion-orb-img {
width: 152rpx;
height: 152rpx;
margin: 34rpx auto 0;
border-radius: 50%;
}
.companion-orb {
text-align: center;
line-height: 152rpx;
font-size: 48rpx;
@ -916,7 +1055,6 @@
radial-gradient(circle, rgba(22,27,46,.34) 0 1rpx, transparent 1rpx),
radial-gradient(circle, rgba(139,124,255,.22) 0 1rpx, transparent 1rpx);
background-size: 42rpx 42rpx, 68rpx 68rpx;
animation: noiseFloat 9s steps(6) infinite;
}
.ie-page::after {
@ -928,8 +1066,7 @@
height: 120%;
pointer-events: none;
background: linear-gradient(120deg, transparent 15%, rgba(255,255,255,.42), transparent 45%, rgba(169,255,231,.24), transparent 70%);
filter: blur(18rpx);
animation: auroraMove 12s ease-in-out infinite alternate;
opacity: .45;
}
.status-head {
@ -946,6 +1083,46 @@
color: #161b2e;
}
.profile-line {
display: flex;
align-items: center;
width: 520rpx;
margin-top: 18rpx;
padding: 18rpx 22rpx;
border-radius: 30rpx;
background: rgba(255,255,255,.58);
border: 1rpx solid rgba(255,255,255,.78);
box-shadow: 0 18rpx 54rpx rgba(96,112,160,.1);
}
.profile-avatar,
.profile-avatar-img {
width: 72rpx;
height: 72rpx;
margin-right: 18rpx;
border-radius: 50%;
}
.profile-avatar {
text-align: center;
line-height: 72rpx;
color: #11162a;
background: linear-gradient(145deg, #effffb, #a9ffe7);
font-size: 28rpx;
font-weight: 800;
}
.profile-name {
font-size: 28rpx;
font-weight: 800;
}
.profile-tags {
margin-top: 6rpx;
color: rgba(22,27,46,.44);
font-size: 21rpx;
}
.live-line {
width: 560rpx;
margin-top: 18rpx;
@ -1123,13 +1300,12 @@
linear-gradient(135deg, rgba(255,255,255,.88), rgba(255,255,255,.54)),
radial-gradient(circle at 18% 0%, rgba(169,255,231,.26), transparent 180rpx),
radial-gradient(circle at 88% 100%, rgba(255,184,209,.16), transparent 220rpx);
backdrop-filter: blur(30rpx);
backdrop-filter: blur(16rpx);
box-shadow:
0 28rpx 80rpx rgba(96,112,160,.16),
inset 0 1rpx 0 rgba(255,255,255,.96),
inset 0 -1rpx 0 rgba(139,124,255,.08);
overflow: hidden;
animation: panelBreath 5.6s ease-in-out infinite;
}
.mood-dock::before {
@ -1140,7 +1316,7 @@
width: 140%;
height: 120%;
background: linear-gradient(110deg, transparent 18%, rgba(255,255,255,.48), transparent 58%);
animation: glassSweep 7s ease-in-out infinite;
opacity: .2;
pointer-events: none;
}
@ -1153,6 +1329,59 @@
font-weight: 650;
}
.target-dock {
position: relative;
z-index: 3;
margin-top: 22rpx;
padding: 24rpx 28rpx;
border-radius: 36rpx;
background: rgba(255,255,255,.66);
border: 1rpx solid rgba(255,255,255,.86);
box-shadow: 0 20rpx 64rpx rgba(96,112,160,.12);
backdrop-filter: blur(12rpx);
}
.target-title {
color: rgba(22,27,46,.54);
font-size: 23rpx;
font-weight: 800;
}
.target-row {
display: flex;
margin-top: 18rpx;
}
.target-chip {
height: 62rpx;
line-height: 62rpx;
padding: 0 24rpx;
margin-right: 14rpx;
border-radius: 999rpx;
color: rgba(22,27,46,.52);
background: rgba(238,244,255,.78);
font-size: 23rpx;
font-weight: 700;
}
.target-chip.active {
color: #11162a;
background: #a9ffe7;
}
.start-match-btn {
height: 88rpx;
line-height: 88rpx;
margin-top: 22rpx;
border-radius: 999rpx;
text-align: center;
color: #11162a;
background: linear-gradient(135deg, #effffb, #a9ffe7 62%, #ffd6a5);
box-shadow: 0 18rpx 46rpx rgba(169,255,231,.26);
font-size: 28rpx;
font-weight: 900;
}
.dock-scroll {
width: 100%;
margin-top: 20rpx;
@ -1621,7 +1850,7 @@
0 0 34rpx currentColor,
inset 3rpx 4rpx 8rpx rgba(255,255,255,.68),
inset -4rpx -5rpx 10rpx rgba(108,92,231,.18);
transition: transform .08s linear, opacity .08s linear, filter .08s linear;
transition: transform .16s linear, opacity .16s linear;
}
.orbit-planet::after {
@ -1653,7 +1882,7 @@
border: 12rpx solid rgba(111, 231, 224, .34);
box-shadow: 0 0 34rpx rgba(169,255,231,.3), inset 0 0 22rpx rgba(255,255,255,.44);
transform: rotateX(66deg) rotateZ(-14deg);
animation: ringDrift 9s linear infinite;
animation: ringDrift 18s linear infinite;
}
.ring-second {
@ -1663,7 +1892,7 @@
margin-top: -54rpx;
border: 4rpx solid rgba(162,155,254,.36);
transform: rotateX(72deg) rotateZ(24deg) translateZ(24rpx);
animation: ringDrift 12s linear infinite reverse;
animation: ringDrift 24s linear infinite reverse;
}
.ring-third {
@ -1673,7 +1902,7 @@
margin-top: -146rpx;
border: 3rpx solid rgba(255,214,165,.34);
transform: rotateX(18deg) rotateY(64deg);
animation: ringDrift 15s linear infinite;
animation: ringDrift 30s linear infinite;
}
.galaxy-body .liquid-a {

40
package1/ieBrowser/match.vue

@ -24,21 +24,27 @@
</view>
<view>
<text class="label">剩余机会</text>
<text class="value">3/3</text>
<text class="value">{{ chancesLeft }}/{{ dailyQuota }}</text>
</view>
</view>
<view class="start" @tap="start">开始调频</view>
<view class="tips">不展示真实头像距离和学校连接只在限时房间里发生</view>
<view class="tips">不展示真实头像距离和学校匹配成功后会保留聊天入口</view>
</view>
</template>
<script>
import { ieHome, startIeMatch } from '@/common/ieApi.js'
export default {
data() {
return {
mode: 'i',
mood: 'quiet',
targetMode: 'any',
targetGender: 'any',
dailyQuota: 3,
chancesLeft: 3,
menuButtonInfo: { top: 44 }
}
},
@ -53,10 +59,36 @@
}
this.mode = options.mode || 'i'
this.mood = options.mood || 'quiet'
this.targetMode = options.targetMode || 'any'
this.targetGender = options.targetGender || 'any'
this.loadQuota()
},
methods: {
start() {
uni.navigateTo({ url: '/package1/ieBrowser/matching?mode=' + this.mode + '&mood=' + this.mood })
async loadQuota() {
const home = await ieHome()
if (!home) return
this.dailyQuota = home.dailyQuota || 3
this.chancesLeft = Math.max(this.dailyQuota - (home.usedQuota || 0), 0)
},
async start() {
if (this.chancesLeft <= 0) {
uni.showToast({ title: '今天先到这里', icon: 'none' })
return
}
const match = await startIeMatch({ mode: this.mode, mood: this.mood, targetMode: this.targetMode, targetGender: this.targetGender })
if (!match || !match.roomId) {
uni.showToast({ title: (match && match.failReason) || '暂时没有同频的人', icon: 'none' })
return
}
uni.navigateTo({
url: '/package1/ieBrowser/matching?mode=' + this.mode + '&mood=' + this.mood +
'&roomId=' + match.roomId + '&targetUserId=' + match.targetUserId +
'&name=' + encodeURIComponent(match.anonymousName || '半匿名漂流者') +
'&avatar=' + encodeURIComponent(match.avatarText || '◌') +
'&avatarUrl=' + encodeURIComponent(match.avatarUrl || '') +
'&state=' + encodeURIComponent(match.stateText || '') +
'&quote=' + encodeURIComponent(match.quoteText || '')
})
},
back() {
uni.redirectTo({ url: '/package1/ieBrowser/index' })

4
package1/ieBrowser/matching.vue

@ -27,6 +27,7 @@
return {
mode: 'i',
mood: 'quiet',
matchQuery: '',
currentCopy: 0,
copyList: ['模糊人格标签正在漂浮', '两个光点正在靠近', '情绪文字短暂闪了一下']
}
@ -34,6 +35,7 @@
onLoad(options) {
this.mode = options.mode || 'i'
this.mood = options.mood || 'quiet'
this.matchQuery = Object.keys(options || {}).map(key => key + '=' + encodeURIComponent(options[key])).join('&')
this.timer = setInterval(() => {
this.currentCopy = (this.currentCopy + 1) % this.copyList.length
}, 700)
@ -51,7 +53,7 @@
},
goCard() {
clearInterval(this.timer)
uni.redirectTo({ url: '/package1/ieBrowser/companion?mode=' + this.mode + '&mood=' + this.mood })
uni.redirectTo({ url: '/package1/ieBrowser/companion?' + this.matchQuery })
}
}
}

69
package1/ieBrowser/messages.vue

@ -10,43 +10,80 @@
<view class="title">消息</view>
<view class="sub">这里不是聊天列表只放未完成的陪伴</view>
</view>
<view class="room-card active" @tap="goChat">
<view class="pulse"></view>
<view class="main">
<view class="name">月台旁的影子</view>
<view class="desc">限时房间还剩 08:32可以继续回去待一会</view>
</view>
<view class="tag">进行中</view>
</view>
<view class="room-card" v-for="item in requests" :key="item.name">
<view class="room-card" v-for="item in requests" :key="item.id" @tap="openRecord(item)">
<view class="avatar">{{ item.avatar }}</view>
<view class="main">
<view class="name">{{ item.name }}</view>
<view class="desc">{{ item.desc }}</view>
</view>
<view class="tag soft">再遇见</view>
<view class="tag soft" v-if="item.remeet">再遇见</view>
</view>
<view class="empty">没有未完成的关系也是一种轻松</view>
<view class="empty" v-if="!loading && !requests.length">没有未完成的关系也是一种轻松</view>
<view class="empty" v-else>{{ loading ? '正在加载...' : (hasMore ? '上滑加载更多' : '没有更多消息了') }}</view>
</view>
</template>
<script>
import { pageIeRecords } from '@/common/ieApi.js'
export default {
data() {
return {
menuButtonInfo: { top: 44 },
requests: [
{ name: '便利店灯光', avatar: '光', desc: '对方也愿意在 24 小时内再聊一次。' },
{ name: '耳机里的风', avatar: '风', desc: '昨天的安静陪伴已自然结束。' }
]
requests: [],
pageNumber: 1,
pageSize: 10,
hasMore: true,
loading: false
}
},
onLoad() {
if (uni.getMenuButtonBoundingClientRect) this.menuButtonInfo = uni.getMenuButtonBoundingClientRect()
this.loadRecords()
},
onReachBottom() {
this.loadRecords()
},
methods: {
normalizeRecord(item) {
const remeet = item.remeetAvailable === 1
return {
id: item.id,
roomId: item.roomId,
targetUserId: item.targetUserId,
mode: item.mode || 'i',
name: item.anonymousName || '半匿名漂流者',
avatar: item.mode === 'e' ? '光' : '月',
desc: (item.unreadCount > 0 ? `${item.unreadCount} 条未读 · ` : '') + (remeet ? '可以从聊天记录继续这段对话。' : (item.summary || '这段聊天已保留在记录里。')),
remeet
}
},
async loadRecords() {
if (this.loading || !this.hasMore) return
this.loading = true
try {
const page = await pageIeRecords(this.pageNumber, this.pageSize)
const list = page && page.records ? page.records.map(this.normalizeRecord) : []
const exists = new Set(this.requests.map(item => item.id))
this.requests = this.requests.concat(list.filter(item => !exists.has(item.id)))
this.hasMore = page ? (page.current || this.pageNumber) < (page.pages || this.pageNumber) : false
this.pageNumber += 1
} finally {
this.loading = false
}
},
back() { uni.redirectTo({ url: '/package1/ieBrowser/index' }) },
goChat() { uni.navigateTo({ url: '/package1/ieBrowser/chat?mode=i' }) }
openRecord(item) {
if (!item || !item.roomId) return
uni.navigateTo({
url: '/package1/ieBrowser/chat?mode=' + item.mode +
'&roomId=' + item.roomId +
'&targetUserId=' + (item.targetUserId || '') +
'&name=' + encodeURIComponent(item.name || '半匿名漂流者') +
'&avatar=' + encodeURIComponent(item.avatar || '◌') +
'&quote=' + encodeURIComponent(item.desc || '')
})
}
}
}
</script>

248
package1/ieBrowser/profileSetup.vue

@ -0,0 +1,248 @@
<template>
<view class="setup-page">
<view class="top-safe" :style="{ height: menuButtonInfo.top + 'px' }"></view>
<view class="nav">
<view class="back" @tap="back"></view>
<view class="title">{{ isEdit ? '编辑 i/e 资料' : '先认识一下你' }}</view>
<view class="step">{{ step }}/4</view>
</view>
<view class="progress"><view :style="{ width: step * 25 + '%' }"></view></view>
<view class="card" v-if="step === 1">
<view class="eyebrow">Your Energy</view>
<view class="headline">今天更像 i 还是 e</view>
<view class="mode-card i" :class="{ active: form.currentMode === 'i' }" @tap="form.currentMode = 'i'">
<view class="mark">i</view>
<view><view class="mode-title">安静陪伴</view><view class="mode-desc">慢回复允许沉默低压力靠近</view></view>
</view>
<view class="mode-card e" :class="{ active: form.currentMode === 'e' }" @tap="form.currentMode = 'e'">
<view class="mark">e</view>
<view><view class="mode-title">轻轻热闹</view><view class="mode-desc">有人开场聊点废话把情绪拉亮</view></view>
</view>
</view>
<view class="card" v-if="step === 2">
<view class="eyebrow">Light Profile</view>
<view class="headline">设置一个轻社交资料</view>
<view class="avatar-wrap" @tap="chooseAvatar">
<image class="avatar-img" v-if="form.avatarUrl" :src="form.avatarUrl" mode="aspectFill"></image>
<view class="avatar-text" v-else>{{ avatarPreview }}</view>
<view class="avatar-tip">点一下换头像</view>
</view>
<view class="random-name" @tap="randomName">随机一个昵称</view>
<input class="input" v-model="form.anonymousName" maxlength="16" placeholder="昵称,例如:南门慢跑员" />
<view class="mini-title">性别</view>
<view class="gender-row">
<view class="gender-chip" v-for="item in genderOptions" :key="item.key" :class="{ active: form.gender === item.key }" @tap="form.gender = item.key">
{{ item.label }}
</view>
</view>
<textarea class="textarea" v-model="form.intro" maxlength="80" placeholder="一句轻介绍:我通常慢热,但会认真听。" />
</view>
<view class="card" v-if="step === 3">
<view class="eyebrow">Tags</view>
<view class="headline">选择几个此刻标签</view>
<view class="tag-grid">
<view class="tag" v-for="item in tagOptions" :key="item" :class="{ active: form.interestTags.includes(item) }" @tap="toggleTag(item)">
{{ item }}
</view>
</view>
</view>
<view class="card" v-if="step === 4">
<view class="eyebrow">Match Target</view>
<view class="headline">你现在想遇见哪类人</view>
<view class="target-card" v-for="item in targetOptions" :key="item.key" :class="{ active: form.targetModePreference === item.key }" @tap="form.targetModePreference = item.key">
<view class="target-title">{{ item.title }}</view>
<view class="target-desc">{{ item.desc }}</view>
</view>
<view class="mini-title target-gender-title">想匹配的性别</view>
<view class="gender-row">
<view class="gender-chip" v-for="item in targetGenderOptions" :key="item.key" :class="{ active: form.targetGenderPreference === item.key }" @tap="form.targetGenderPreference = item.key">
{{ item.label }}
</view>
</view>
</view>
<view class="actions">
<view class="ghost" @tap="prev" v-if="step > 1">上一步</view>
<view class="solid" @tap="next">{{ step === 4 ? '进入 i/e 此刻' : '继续' }}</view>
</view>
</view>
</template>
<script>
import { getIeProfile, saveIeProfile } from '@/common/ieApi.js'
import tui from '@/common/httpRequest.js'
export default {
data() {
return {
menuButtonInfo: { top: 44 },
step: 1,
isEdit: false,
submitting: false,
profileCompleted: 0,
form: {
currentMode: 'i',
targetModePreference: 'any',
targetGenderPreference: 'any',
anonymousName: '',
avatarText: '我',
avatarUrl: '',
gender: 'unknown',
intro: '',
interestTags: []
},
tagOptions: ['慢热', '爱听歌', '夜跑', '自习', '想聊天', '想安静', '情绪低电量', '散步', '电影', '游戏', '摄影', '干饭'],
targetOptions: [
{ key: 'i', title: '想遇见 i 人', desc: '安静、慢一点、允许沉默。' },
{ key: 'e', title: '想遇见 e 人', desc: '轻松开场,聊一点不重要的小事。' },
{ key: 'any', title: '都可以', desc: '交给此刻的随机同频。' }
],
genderOptions: [{ key: 'male', label: '男生' }, { key: 'female', label: '女生' }, { key: 'unknown', label: '不想说' }],
targetGenderOptions: [{ key: 'male', label: '男生' }, { key: 'female', label: '女生' }, { key: 'any', label: '不限' }],
nameSeeds: ['南门慢跑员', '图书馆三楼', '便利店灯光', '耳机里的云', '操场晚风', '自习逃跑员', '月台旁的影子']
}
},
computed: {
avatarPreview() {
return (this.form.anonymousName || this.form.avatarText || '我').slice(0, 1)
}
},
onLoad(options) {
if (uni.getMenuButtonBoundingClientRect) this.menuButtonInfo = uni.getMenuButtonBoundingClientRect()
this.isEdit = options && options.edit === '1'
this.loadProfile()
},
methods: {
async loadProfile() {
const profile = await getIeProfile()
if (!profile) return
this.profileCompleted = profile.profileCompleted || 0
this.form.currentMode = profile.currentMode || 'i'
this.form.targetModePreference = profile.targetModePreference || 'any'
this.form.targetGenderPreference = profile.targetGenderPreference || 'any'
this.form.anonymousName = this.isEdit || profile.profileCompleted === 1 ? (profile.anonymousName || '') : ''
this.form.avatarText = profile.avatarText || '我'
this.form.avatarUrl = profile.avatarUrl || ''
this.form.gender = profile.gender || 'unknown'
this.form.intro = profile.intro || ''
this.form.interestTags = profile.interestTags || []
},
toggleTag(tag) {
const index = this.form.interestTags.indexOf(tag)
if (index > -1) {
this.form.interestTags.splice(index, 1)
return
}
if (this.form.interestTags.length >= 6) {
uni.showToast({ title: '最多选择 6 个标签', icon: 'none' })
return
}
this.form.interestTags.push(tag)
},
randomName() {
this.form.anonymousName = this.nameSeeds[Math.floor(Math.random() * this.nameSeeds.length)]
},
chooseAvatar() {
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: async (res) => {
const filePath = res.tempFilePaths && res.tempFilePaths[0]
if (!filePath) return
const result = await tui.uploadFile('/upload/file', filePath)
const url = typeof result === 'string' ? result : (result.url || result.fileUrl || result.path || result.fullPath || result)
if (url && typeof url === 'string') this.form.avatarUrl = url
}
})
},
validateStep() {
if (this.step === 2 && !this.form.anonymousName.trim()) {
uni.showToast({ title: '先起一个昵称吧', icon: 'none' })
return false
}
if (this.step === 3 && !this.form.interestTags.length) {
uni.showToast({ title: '至少选一个标签', icon: 'none' })
return false
}
return true
},
prev() { this.step = Math.max(1, this.step - 1) },
async next() {
if (this.submitting) return
if (!this.validateStep()) return
if (this.step < 4) {
this.step += 1
return
}
this.submitting = true
uni.showLoading({ title: '正在进入 i/e', mask: true })
try {
const profile = await saveIeProfile({
...this.form,
avatarText: this.avatarPreview
})
if (!profile) return
uni.redirectTo({ url: this.isEdit ? '/package1/ieBrowser/universe' : '/package1/ieBrowser/index' })
} finally {
this.submitting = false
uni.hideLoading()
}
},
back() {
if (this.isEdit) {
uni.redirectTo({ url: '/package1/ieBrowser/universe' })
return
}
uni.redirectTo({ url: '/pages/index/index' })
}
}
}
</script>
<style lang="scss" scoped>
page { background: #f7f9ff; }
.setup-page { min-height: 100vh; padding: 0 32rpx 168rpx; box-sizing: border-box; color: #151a2d; background: radial-gradient(circle at 82% 10%, rgba(169,255,231,.42), transparent 320rpx), radial-gradient(circle at 16% 70%, rgba(255,184,209,.24), transparent 340rpx), linear-gradient(180deg, #fbfdff, #eef4ff 64%, #fff4e8); }
.nav { height: 92rpx; display: flex; align-items: center; }
.back { width: 70rpx; color: rgba(21,26,45,.56); font-size: 56rpx; }
.title { flex: 1; font-size: 32rpx; font-weight: 800; }
.step { color: #6c69d8; font-size: 24rpx; font-weight: 800; }
.progress { height: 10rpx; border-radius: 999rpx; background: rgba(21,26,45,.06); overflow: hidden; }
.progress view { height: 100%; border-radius: 999rpx; background: #a9ffe7; transition: width .25s ease; }
.card { margin-top: 36rpx; padding: 38rpx 32rpx; border-radius: 46rpx; border: 1rpx solid rgba(255,255,255,.82); background: rgba(255,255,255,.64); box-shadow: 0 26rpx 80rpx rgba(96,112,160,.14); backdrop-filter: blur(26rpx); }
.eyebrow { color: rgba(21,26,45,.42); font-size: 22rpx; letter-spacing: 5rpx; text-transform: uppercase; }
.headline { margin-top: 16rpx; margin-bottom: 28rpx; font-size: 46rpx; line-height: 60rpx; font-weight: 800; }
.mode-card, .target-card { display: flex; align-items: center; margin-top: 22rpx; padding: 28rpx; border-radius: 34rpx; background: rgba(238,244,255,.68); border: 2rpx solid transparent; }
.mode-card.active, .target-card.active { border-color: #a9ffe7; background: rgba(223,254,244,.7); transform: scale(1.01); }
.mark { width: 82rpx; height: 82rpx; margin-right: 22rpx; border-radius: 28rpx; text-align: center; line-height: 82rpx; font-size: 46rpx; font-weight: 800; }
.i .mark { color: #fff; background: #7771d8; }
.e .mark { color: #11162a; background: #a9ffe7; }
.mode-title, .target-title { font-size: 30rpx; font-weight: 800; }
.mode-desc, .target-desc { margin-top: 8rpx; color: rgba(21,26,45,.54); font-size: 23rpx; line-height: 36rpx; }
.mini-title { margin: 24rpx 0 16rpx; color: rgba(21,26,45,.5); font-size: 23rpx; font-weight: 800; }
.target-gender-title { margin-top: 30rpx; }
.gender-row { display: flex; flex-wrap: wrap; }
.gender-chip { height: 58rpx; line-height: 58rpx; margin: 0 14rpx 14rpx 0; padding: 0 24rpx; border-radius: 999rpx; color: rgba(21,26,45,.58); background: rgba(238,244,255,.82); font-size: 23rpx; }
.gender-chip.active { color: #11162a; background: #a9ffe7; font-weight: 800; }
.avatar-wrap { width: 184rpx; margin: 0 auto 30rpx; text-align: center; }
.avatar-img, .avatar-text { width: 154rpx; height: 154rpx; margin: 0 auto; border-radius: 50%; }
.avatar-img { display: block; }
.avatar-text { text-align: center; line-height: 154rpx; color: #11162a; background: linear-gradient(145deg, #effffb, #a9ffe7); font-size: 58rpx; font-weight: 800; }
.avatar-tip { margin-top: 14rpx; color: rgba(21,26,45,.42); font-size: 22rpx; }
.random-name { width: 220rpx; height: 58rpx; margin: 0 auto 22rpx; border-radius: 999rpx; text-align: center; line-height: 58rpx; color: #6c69d8; background: rgba(139,124,255,.1); font-size: 23rpx; font-weight: 800; }
.input, .textarea { width: 100%; box-sizing: border-box; border-radius: 28rpx; background: rgba(238,244,255,.78); color: #151a2d; font-size: 27rpx; }
.input { height: 88rpx; padding: 0 26rpx; }
.textarea { height: 180rpx; margin-top: 22rpx; padding: 24rpx 26rpx; line-height: 40rpx; }
.tag-grid { display: flex; flex-wrap: wrap; }
.tag { margin: 0 14rpx 18rpx 0; padding: 16rpx 24rpx; border-radius: 999rpx; color: rgba(21,26,45,.62); background: rgba(238,244,255,.82); font-size: 24rpx; }
.tag.active { color: #11162a; background: #a9ffe7; font-weight: 800; }
.actions { position: fixed; left: 32rpx; right: 32rpx; bottom: 42rpx; display: flex; gap: 18rpx; }
.ghost, .solid { flex: 1; height: 96rpx; border-radius: 999rpx; text-align: center; line-height: 96rpx; font-size: 28rpx; font-weight: 800; }
.ghost { color: rgba(21,26,45,.58); background: rgba(255,255,255,.68); }
.solid { color: #11162a; background: linear-gradient(135deg, #effffb, #a9ffe7); box-shadow: 0 22rpx 58rpx rgba(169,255,231,.28); }
</style>

2
package1/ieBrowser/settings.vue

@ -27,7 +27,7 @@
groups: [
{ title: '隐私', items: [
{ name: '半匿名资料', desc: '不展示真实头像、学校、距离。' },
{ name: '聊天保留规则', desc: '只保存情绪记录,不保存完整聊天。' }
{ name: '聊天保留规则', desc: '匹配成功后保留聊天入口和历史消息。' }
] },
{ title: '安全', items: [
{ name: '黑名单', desc: '被拉黑对象不会再次出现。' },

33
package1/ieBrowser/universe.vue

@ -7,14 +7,19 @@
<view class="ghost"></view>
</view>
<view class="profile">
<view class="orb"></view>
<view class="name">半匿名漂流者</view>
<view class="desc">不经营人设不展示粉丝这里只记录你更喜欢怎样被陪伴</view>
<image class="orb-img" v-if="profile.avatarUrl" :src="profile.avatarUrl" mode="aspectFill"></image>
<view class="orb" v-else>{{ profile.avatarText || '' }}</view>
<view class="name">{{ profile.anonymousName || '半匿名漂流者' }}</view>
<view class="desc">{{ profile.intro || '不经营人设,不展示粉丝。这里只记录你更喜欢怎样被陪伴。' }}</view>
<view class="edit-profile" @tap="editProfile">编辑资料</view>
</view>
<view class="stats">
<view><text>3</text><text>今日机会</text></view>
<view><text>15m</text><text>默认限时</text></view>
<view><text>i</text><text>近期偏好</text></view>
<view><text>{{ profile.dailyQuota || 3 }}</text><text>今日机会</text></view>
<view><text>长期</text><text>聊天保留</text></view>
<view><text>{{ profile.currentMode || 'i' }}</text><text>当前状态</text></view>
</view>
<view class="tag-panel" v-if="profile.interestTags && profile.interestTags.length">
<view class="tag" v-for="item in profile.interestTags" :key="item">{{ item }}</view>
</view>
<view class="panel">
<view class="panel-title">情绪轨道</view>
@ -37,10 +42,13 @@
</template>
<script>
import { getIeProfile } from '@/common/ieApi.js'
export default {
data() {
return {
menuButtonInfo: { top: 44 },
profile: {},
orbit: [
{ day: '今天', value: 72, mood: '轻了一点' },
{ day: '昨天', value: 48, mood: '安静' },
@ -50,9 +58,15 @@
},
onLoad() {
if (uni.getMenuButtonBoundingClientRect) this.menuButtonInfo = uni.getMenuButtonBoundingClientRect()
this.loadProfile()
},
methods: {
async loadProfile() {
const profile = await getIeProfile()
if (profile) this.profile = profile
},
back() { uni.redirectTo({ url: '/package1/ieBrowser/index' }) },
editProfile() { uni.navigateTo({ url: '/package1/ieBrowser/profileSetup?edit=1' }) },
goSettings() { uni.navigateTo({ url: '/package1/ieBrowser/settings' }) }
}
}
@ -65,14 +79,19 @@
.back, .ghost { width: 70rpx; font-size: 56rpx; color: rgba(22,27,46,.55); }
.nav-title { flex: 1; text-align: center; font-size: 30rpx; font-weight: 800; }
.profile { padding-top: 18rpx; text-align: center; }
.orb { width: 148rpx; height: 148rpx; margin: 0 auto; border-radius: 50%; text-align: center; line-height: 148rpx; color: #11162a; background: linear-gradient(145deg, #effffb, #a9ffe7); font-size: 48rpx; font-weight: 800; box-shadow: 0 0 80rpx rgba(169,255,231,.18); }
.orb,
.orb-img { width: 148rpx; height: 148rpx; margin: 0 auto; border-radius: 50%; box-shadow: 0 0 80rpx rgba(169,255,231,.18); }
.orb { text-align: center; line-height: 148rpx; color: #11162a; background: linear-gradient(145deg, #effffb, #a9ffe7); font-size: 48rpx; font-weight: 800; }
.name { margin-top: 26rpx; font-size: 42rpx; font-weight: 800; }
.desc { width: 560rpx; margin: 14rpx auto 0; color: rgba(22,27,46,.52); font-size: 24rpx; line-height: 38rpx; }
.edit-profile { display: inline-block; margin-top: 22rpx; padding: 12rpx 28rpx; border-radius: 999rpx; color: #11162a; background: #a9ffe7; font-size: 23rpx; font-weight: 800; }
.stats { display: flex; margin-top: 36rpx; padding: 28rpx; border-radius: 36rpx; background: rgba(255,255,255,.62); border: 1rpx solid rgba(255,255,255,.78); box-shadow: 0 20rpx 64rpx rgba(96,112,160,.12); }
.stats view { flex: 1; text-align: center; }
.stats text { display: block; }
.stats text:first-child { font-size: 34rpx; font-weight: 800; }
.stats text:last-child { margin-top: 8rpx; color: rgba(22,27,46,.42); font-size: 21rpx; }
.tag-panel { display: flex; flex-wrap: wrap; margin-top: 24rpx; padding: 24rpx; border-radius: 32rpx; background: rgba(255,255,255,.58); border: 1rpx solid rgba(255,255,255,.78); }
.tag { margin: 0 12rpx 12rpx 0; padding: 10rpx 18rpx; border-radius: 999rpx; color: #6c69d8; background: rgba(139,124,255,.1); font-size: 22rpx; }
.panel { margin-top: 26rpx; padding: 30rpx; border-radius: 36rpx; background: rgba(255,255,255,.62); border: 1rpx solid rgba(255,255,255,.78); backdrop-filter: blur(24rpx); box-shadow: 0 20rpx 64rpx rgba(96,112,160,.12); }
.panel-title { margin-bottom: 24rpx; font-size: 30rpx; font-weight: 800; }
.orbit-row { display: flex; align-items: center; margin-top: 22rpx; }

Loading…
Cancel
Save