wangfukang 5 days ago
parent
commit
399a3397c7
  1. 510
      package1/ieBrowser/chat.vue
  2. 130
      package1/ieBrowser/index.vue
  3. 46
      package1/ieBrowser/messages.vue
  4. 414
      package1/ieBrowser/mySpace.vue
  5. 39
      package1/ieBrowser/profileSetup.vue
  6. 137
      package1/ieBrowser/space.vue
  7. 479
      package1/ieBrowser/universe.vue

510
package1/ieBrowser/chat.vue

@ -12,9 +12,6 @@
<view class="room-name">{{ companion.name }}</view>
<view class="room-meta">{{ roomModeText }} · 可以随时回来继续聊</view>
</view>
<view class="streak-pill" v-if="streak.streakDays > 0" :class="{ expiring: streak.expiring }" @tap="showStreakTip">
🔥x{{ streak.streakDays }}
</view>
<view class="more" @tap="showSafety = true">···</view>
</view>
@ -23,7 +20,7 @@
<view class="radio-core">FM</view>
<view class="radio-copy">
<view class="radio-title">电台陪伴中</view>
<view class="radio-sub">你们已经安静陪伴了 {{ silentMinutes }} 分钟</view>
<view class="radio-sub">你们正在安静陪伴中</view>
</view>
<view class="safe-actions">
<view class="safe-btn warn" :class="{ active: blockedByMe }" @tap="toggleBlock">
@ -34,11 +31,13 @@
</view>
<view class="timer-card">
<view>
<view class="timer-info">
<view class="timer-label">请勿发送涉黄涉暴反动侮辱等内容</view>
<view class="timer-title">发送图片音视频消息涉及内容审核可能略有延迟</view>
</view>
<view class="timer-pill">已连接</view>
<view class="timer-pill" :class="{ streak: streak.streakDays > 0, expiring: streak.expiring }" @tap="streak.streakDays > 0 && showStreakTip()">
{{ streak.streakDays > 0 ? '🔥x' + streak.streakDays : '已连接' }}
</view>
</view>
<!-- <view class="silent-modes">
@ -57,6 +56,7 @@
: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="msg-body" :class="{ mine: item.mine }">
<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>
@ -77,13 +77,15 @@
<text>{{ item.mediaDuration || 1 }}''</text>
</view>
<view class="bubble" v-else :class="{ muted: item.pending, blocked: item.blocked }">{{ item.content }}</view>
<view class="audit-tip blocked-tip" v-if="item.mine && item.blocked">内容未通过审核请换一个更合适的内容</view>
<view class="blocked-tip" v-if="item.mine && item.blocked">{{ item.blockTip || '内容未通过审核请换一个更合适的内容' }}</view>
</view>
</view>
<view class="hesitate-line right" v-if="typingHint">{{ typingHint }}</view>
<view id="message-bottom-anchor" class="message-bottom-spacer" :style="{ height: bottomSafePadding }"></view>
</scroll-view>
<view class="presence-row" :style="{ bottom: presenceBottom }">
<view class="presence-chip quiz-chip" @tap="openQuiz">🎯 默契测试</view>
<view class="presence-chip quiz-chip" @tap="openQuiz">🎯 {{ quizButtonText }}</view>
<view class="presence-chip" v-for="item in presenceActions" :key="item" @tap="sendPresence(item)">
{{ item }}
</view>
@ -193,6 +195,11 @@
<image class="profile-persona-image" v-for="(img, index) in targetProfile.personaImages" :key="img" :src="img" mode="aspectFill" @tap="previewTargetPersona(index)"></image>
</scroll-view>
</view>
<view class="profile-space-entry" v-if="targetProfile.userId" @tap="goTargetSpace">
<text class="profile-space-icon">🪐</text>
<text>看TA的个人空间</text>
<text class="profile-space-arrow"></text>
</view>
<view class="profile-close" @tap="showTargetProfile = false">继续聊天</view>
</view>
</view>
@ -232,6 +239,9 @@
uploadingVoice: false,
recording: false,
voiceCancelling: false,
// idle | starting | recording stop start
recordPhase: 'idle',
pendingStop: false,
recordStartY: 0,
recordStartTime: 0,
recorderManager: null,
@ -246,6 +256,7 @@
quiz: { exists: false, questions: [] },
quizPicks: ['', '', '', '', ''],
quizPolling: null,
quizNoticeId: '',
messages: [],
messagePage: 1,
messagePageSize: 20,
@ -305,6 +316,9 @@
keyboardBottomPx() {
return this.keyboardHeight > 0 ? (this.keyboardHeight + 8) + 'px' : '30rpx'
},
bottomSafePadding() {
return this.keyboardHeight > 0 ? (this.keyboardHeight + 188) + 'px' : '260rpx'
},
inputBarBottom() {
return this.keyboardBottomPx
},
@ -315,9 +329,7 @@
return this.keyboardHeight > 0 ? (this.keyboardHeight + 82) + 'px' : '128rpx'
},
messagesStyle() {
return {
paddingBottom: this.keyboardHeight > 0 ? (this.keyboardHeight + 140) + 'px' : '24rpx'
}
return {}
},
quizComplete() {
return this.quizPicks.every(pick => pick === 'A' || pick === 'B')
@ -330,6 +342,14 @@
if (rate >= 60) return '相当同频,可以处'
if (rate >= 40) return '互补型搭子,刚刚好'
return '来自两个星球,但这才有趣'
},
quizButtonText() {
if (!this.quiz || !this.quiz.exists) return '默契测试'
if (this.quiz.bothDone) return '查看默契'
if (this.quiz.myDone) return '等TA答题'
const selfId = String(uni.getStorageSync('id') || '')
if (this.quiz.starterId && String(this.quiz.starterId) !== selfId) return 'TA发起测试'
return '继续测试'
}
},
onLoad(options) {
@ -360,10 +380,12 @@
this.startMessagePolling()
this.bindKeyboardListener()
this.loadStreak()
this.startQuizPolling()
},
onShow() {
if (this.roomId) {
this.startMessagePolling()
this.startQuizPolling()
this.pullNewMessages()
}
},
@ -485,17 +507,38 @@
: `连续 ${this.streak.streakDays} 天互相陪伴,每满 ${this.streak.rewardInterval || 7} 天双方各得一次匹配机会`
uni.showToast({ title: tip, icon: 'none', duration: 2600 })
},
openQuiz() {
this.showQuiz = true
const handle = (res) => {
applyQuizState(res, options = {}) {
if (!res) return
const previousId = this.quiz && this.quiz.quizId
const previousBothDone = this.quiz && this.quiz.bothDone
this.quiz = res
if (options.resetPicks && !res.myDone) {
this.quizPicks = ['', '', '', '', '']
if (res.myDone && !res.bothDone) {
}
const selfId = String(uni.getStorageSync('id') || '')
if (res.exists && res.quizId && res.starterId && String(res.starterId) !== selfId && !res.myDone && this.quizNoticeId !== res.quizId) {
this.quizNoticeId = res.quizId
if (!this.showQuiz) {
uni.showToast({ title: 'TA发起了默契测试', icon: 'none', duration: 2200 })
}
}
if (res.exists && res.bothDone && !previousBothDone && previousId === res.quizId && !this.showQuiz) {
uni.showToast({ title: `默契测试揭晓:${res.matchRate}%`, icon: 'none', duration: 2600 })
}
if (res.exists && res.bothDone) {
this.stopQuizPolling()
}
},
openQuiz() {
if (!this.roomId) return
this.showQuiz = true
const handle = (res) => {
this.applyQuizState(res, { resetPicks: true })
if (res && !res.bothDone) {
this.startQuizPolling()
}
}
if (this.quiz.exists && !this.quiz.bothDone) {
if (this.quiz.exists) {
getIeQuizCurrent(this.roomId).then(handle).catch(() => {})
return
}
@ -503,8 +546,8 @@
},
closeQuizPanel() {
this.showQuiz = false
// toast
if (!this.quiz.myDone || this.quiz.bothDone) {
//
if (this.quiz.bothDone) {
this.stopQuizPolling()
}
},
@ -518,33 +561,21 @@
return
}
answerIeQuiz(this.roomId, this.quizPicks.join('')).then(res => {
if (!res) return
this.quiz = res
if (!res.bothDone) {
this.applyQuizState(res)
if (res && !res.bothDone) {
this.startQuizPolling()
}
}).catch(() => {})
},
startQuizPolling() {
if (!this.roomId) return
if (this.quiz && this.quiz.bothDone) return
this.stopQuizPolling()
let ticks = 0
this.quizPolling = setInterval(() => {
if (++ticks > 100) {
this.stopQuizPolling()
return
}
getIeQuizCurrent(this.roomId).then(res => {
if (res && res.exists) {
this.quiz = res
if (res.bothDone) {
this.stopQuizPolling()
if (!this.showQuiz) {
uni.showToast({ title: `默契测试揭晓:${res.matchRate}%`, icon: 'none', duration: 2600 })
}
}
}
this.applyQuizState(res)
}).catch(() => {})
}, 3000)
}, 5000)
},
stopQuizPolling() {
if (this.quizPolling) {
@ -557,8 +588,14 @@
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))
// ack messageId id
const exists = new Set()
this.messages.forEach(item => {
if (item.messageId) exists.add(String(item.messageId))
if (item.clientMsgId) exists.add(item.clientMsgId)
})
const appended = latest.filter(item => !(item.messageId && exists.has(String(item.messageId))) &&
!(item.clientMsgId && exists.has(item.clientMsgId)))
this.mergeMessageStatus(latest)
if (!appended.length) return
this.messages = this.messages.concat(appended)
@ -624,10 +661,9 @@
},
scrollToBottom(animated = true) {
this.$nextTick(() => {
const last = this.messages[this.messages.length - 1]
if (!last) return
if (!this.messages.length) return
this.scrollAnimation = animated
this.scrollIntoView = last.domId
this.scrollIntoView = 'message-bottom-anchor'
})
},
sendPresence(text) {
@ -649,6 +685,46 @@
return
}
this.voiceMode = !this.voiceMode
//
if (this.voiceMode) this.ensureRecordAuth()
},
ensureRecordAuth() {
return new Promise((resolve) => {
uni.getSetting({
success: (res) => {
const auth = res.authSetting || {}
if (auth['scope.record']) {
resolve(true)
return
}
if (auth['scope.record'] === false) {
// authorize
uni.showModal({
title: '需要麦克风权限',
content: '发送语音需要使用麦克风,请在设置中打开「麦克风」权限',
confirmText: '去设置',
success: (modal) => {
if (!modal.confirm) {
resolve(false)
return
}
uni.openSetting({
success: (setting) => resolve(!!(setting.authSetting && setting.authSetting['scope.record'])),
fail: () => resolve(false)
})
}
})
return
}
uni.authorize({
scope: 'scope.record',
success: () => resolve(true),
fail: () => resolve(false)
})
},
fail: () => resolve(true)
})
})
},
sendEmoji(emoji) {
this.showEmoji = false
@ -680,6 +756,21 @@
uni.showToast({ title: '先等等对方回复吧,首次破冰最多发送 3 条', icon: 'none' })
return
}
const clientMsgId = this.pushLocalMessage(content, messageType, media)
this.persistMessage({
roomId: Number(this.roomId),
clientMsgId,
messageType,
content,
mediaDuration: media.mediaDuration,
mediaSize: media.mediaSize,
mediaFormat: media.mediaFormat,
mediaCheckUrl: media.mediaCheckUrl || '',
mediaCheckType: media.mediaCheckType
})
},
// /ack
pushLocalMessage(content, messageType, media = {}) {
// clientMsgId ACK
this.msgSeq = (this.msgSeq || 0) + 1
const clientMsgId = 'm-' + Date.now() + '-' + this.msgSeq
@ -693,30 +784,37 @@
mediaFormat: media.mediaFormat,
poster: media.poster || '',
mine: true,
pending: !this.isAuditMediaMessage(messageType),
pending: true,
blocked: false,
blockTip: '',
mediaAuditStatus: media.mediaAuditStatus || 0
})
this.scrollToBottom()
this.persistMessage({
roomId: Number(this.roomId),
clientMsgId,
messageType,
content,
mediaDuration: media.mediaDuration,
mediaSize: media.mediaSize,
mediaFormat: media.mediaFormat,
mediaCheckUrl: media.mediaCheckUrl || '',
mediaCheckType: media.mediaCheckType
})
return clientMsgId
},
markSendFailed(clientMsgId, tip) {
const msg = this.messages.find(item => item.clientMsgId === clientMsgId)
if (!msg) return
msg.pending = false
msg.blocked = true
msg.blockTip = tip || '发送失败,请稍后再试'
},
canSendMedia() {
if (this.isBlocked) {
uni.showToast({ title: '已加入黑名单,无法继续发送', icon: 'none' })
return false
}
if (this.needWaitReply()) {
uni.showToast({ title: '先等等对方回复吧,首次破冰最多发送 3 条', icon: 'none' })
return false
}
return true
},
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
},
isAuditMediaMessage(messageType) {
return messageType === 2 || messageType === 4 || messageType === 5
},
async persistMessage(payload) {
try {
const ack = await sendIeMessage(this.roomId, payload)
@ -724,21 +822,11 @@
this.applyAck(ack)
return
}
const msg = this.messages.find(item => item.clientMsgId === payload.clientMsgId)
if (msg) {
msg.pending = false
msg.blocked = true
msg.content = '发送失败,请稍后再试'
}
this.markSendFailed(payload.clientMsgId)
} catch (e) {
const message = (e && e.message) || ''
if (message.indexOf('黑名单') >= 0) this.isBlocked = true
const msg = this.messages.find(item => item.clientMsgId === payload.clientMsgId)
if (msg) {
msg.pending = false
msg.blocked = true
msg.content = message || '发送失败,请稍后再试'
}
this.markSendFailed(payload.clientMsgId, message || '发送失败,请稍后再试')
}
},
applyAck(ack) {
@ -755,12 +843,14 @@
msg.mediaAuditStatus = ack.mediaAuditStatus || msg.mediaAuditStatus || 0
msg.blocked = ack.isBlocked === 1
if (msg.blocked) {
msg.content = msg.messageType === 1 ? '这句话没有送出,请换一种更温柔的说法。' : msg.content
if (msg.messageType === 1) msg.content = '这句话没有送出,请换一种更温柔的说法。'
msg.blockTip = '内容未通过审核,请换一个更合适的内容'
if (!wasBlocked) {
uni.showToast({ title: '内容未通过审核,请换一个更合适的内容', icon: 'none' })
}
} else {
msg.content = ack.content || msg.content
//
if (msg.messageType === 1 || msg.messageType === 3) msg.content = ack.content || msg.content
this.loadStreak(true)
}
},
@ -779,13 +869,26 @@
mediaType: ['image', 'video'],
sourceType: ['album', 'camera'],
camera: 'back',
maxDuration: 60,
success: async (res) => {
const file = res.tempFiles && res.tempFiles[0]
if (!file || !file.tempFilePath) return
if (res.type === 'video' || file.fileType === 'video') {
if (file.size && file.size > 100 * 1024 * 1024) {
uni.showToast({ title: '视频不能超过100MB,请先剪辑', icon: 'none' })
return
}
if (file.duration && file.duration > 61) {
uni.showToast({ title: '视频时长不能超过60秒', icon: 'none' })
return
}
await this.uploadAndSendVideo(file)
return
}
if (file.size && file.size > 10 * 1024 * 1024) {
uni.showToast({ title: '图片不能超过10MB', icon: 'none' })
return
}
await this.uploadAndSendImage(file.tempFilePath, file.size)
}
})
@ -793,7 +896,6 @@
chooseImageCompat() {
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: async (res) => {
const filePath = res.tempFilePaths && res.tempFilePaths[0]
@ -802,55 +904,76 @@
}
})
},
extractUploadUrl(result) {
const url = typeof result === 'string' ? result : (result && (result.url || result.fileUrl || result.path || result.fullPath))
return url && typeof url === 'string' ? url : ''
},
async uploadAndSendImage(filePath, fileSize) {
if (!this.canSendMedia()) return
this.showEmoji = false
// +
const clientMsgId = this.pushLocalMessage(filePath, 2, { mediaSize: fileSize, mediaFormat: 'image' })
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' })
const imageUrl = this.extractUploadUrl(await tui.uploadFile('/upload/file', filePath))
if (!imageUrl) {
this.markSendFailed(clientMsgId, '图片上传失败,请稍后再试')
return
}
this.showEmoji = false
this.sendChatContent(imageUrl, 2, {
this.persistMessage({
roomId: Number(this.roomId),
clientMsgId,
messageType: 2,
content: imageUrl,
mediaSize: fileSize,
mediaFormat: 'image',
mediaCheckUrl: imageUrl,
mediaCheckType: 2,
mediaAuditStatus: 1
mediaCheckType: 2
})
} catch (e) {
this.markSendFailed(clientMsgId, '图片上传失败,请稍后再试')
} finally {
this.uploadingImage = false
}
},
async uploadAndSendVideo(file) {
if (!this.canSendMedia()) return
this.showEmoji = false
const mediaDuration = file.duration ? Math.round(file.duration) : undefined
const clientMsgId = this.pushLocalMessage(file.tempFilePath, 5, {
mediaDuration,
mediaSize: file.size,
mediaFormat: 'video',
poster: file.thumbTempFilePath || ''
})
this.uploadingImage = true
try {
const result = await tui.uploadFile('/upload/file', file.tempFilePath)
const videoUrl = typeof result === 'string' ? result : (result.url || result.fileUrl || result.path || result.fullPath || result)
if (!videoUrl || typeof videoUrl !== 'string') {
uni.showToast({ title: '视频上传失败', icon: 'none' })
const videoUrl = this.extractUploadUrl(await tui.uploadFile('/upload/file', file.tempFilePath))
if (!videoUrl) {
this.markSendFailed(clientMsgId, '视频上传失败,请稍后再试')
return
}
let posterUrl = ''
if (file.thumbTempFilePath) {
try {
const posterResult = await tui.uploadFile('/upload/file', file.thumbTempFilePath)
posterUrl = typeof posterResult === 'string' ? posterResult : (posterResult.url || posterResult.fileUrl || posterResult.path || posterResult.fullPath || posterResult)
posterUrl = this.extractUploadUrl(await tui.uploadFile('/upload/file', file.thumbTempFilePath))
} catch (e) {
posterUrl = ''
}
}
this.showEmoji = false
this.sendChatContent(videoUrl, 5, {
mediaDuration: file.duration ? Math.round(file.duration) : undefined,
this.persistMessage({
roomId: Number(this.roomId),
clientMsgId,
messageType: 5,
content: videoUrl,
mediaDuration,
mediaSize: file.size,
mediaFormat: 'video',
poster: posterUrl || file.thumbTempFilePath || '',
mediaCheckUrl: posterUrl,
mediaCheckType: posterUrl ? 2 : undefined,
mediaAuditStatus: 1
mediaCheckType: posterUrl ? 2 : undefined
})
} catch (e) {
this.markSendFailed(clientMsgId, '视频上传失败,请稍后再试')
} finally {
this.uploadingImage = false
}
@ -868,10 +991,20 @@
initRecorder() {
if (!uni.getRecorderManager) return
this.recorderManager = uni.getRecorderManager()
this.recorderManager.onStart(() => {
this.recordPhase = 'recording'
// stop
if (this.pendingStop) {
this.pendingStop = false
this.recorderManager.stop()
}
})
this.recorderManager.onStop(async (res) => {
const cancelled = this.voiceCancelling
this.recording = false
this.voiceCancelling = false
this.recordPhase = 'idle'
this.pendingStop = false
this.recordStartY = 0
if (cancelled) {
uni.showToast({ title: '已取消发送', icon: 'none' })
@ -886,8 +1019,15 @@
await this.uploadAndSendVoice(res.tempFilePath, duration, res.fileSize)
})
this.recorderManager.onError(() => {
const active = this.recordPhase !== 'idle'
this.recording = false
uni.showToast({ title: '录音失败,请检查授权', icon: 'none' })
this.voiceCancelling = false
this.recordPhase = 'idle'
this.pendingStop = false
if (!active) return
uni.showToast({ title: '录音失败,请检查麦克风权限', icon: 'none' })
//
this.ensureRecordAuth()
})
},
initAudio() {
@ -905,17 +1045,28 @@
const touch = (event.touches && event.touches[0]) || (event.changedTouches && event.changedTouches[0])
return touch ? touch.clientY || touch.pageY || 0 : 0
},
startRecord(event) {
async startRecord(event) {
if (this.isBlocked) {
uni.showToast({ title: '已加入黑名单,无法继续发送', icon: 'none' })
return
}
if (!this.recorderManager || this.uploadingVoice) return
if (!this.recorderManager || this.uploadingVoice || this.recordPhase !== 'idle') return
this.showEmoji = false
this.recording = true
this.voiceCancelling = false
this.recordPhase = 'starting'
this.pendingStop = false
this.recordStartY = this.recordTouchPoint(event)
this.recordStartTime = Date.now()
const authed = await this.ensureRecordAuth()
//
if (!authed || this.pendingStop) {
this.recording = false
this.voiceCancelling = false
this.recordPhase = 'idle'
this.pendingStop = false
return
}
this.recorderManager.start({
duration: 60000,
sampleRate: 16000,
@ -931,33 +1082,52 @@
this.voiceCancelling = this.recordStartY - y > 70
},
stopRecord() {
if (!this.recorderManager || !this.recording) return
if (!this.recorderManager || this.recordPhase === 'idle') return
if (this.recordPhase === 'recording') {
this.recorderManager.stop()
} else {
// onStart stop
this.pendingStop = true
}
},
cancelRecord() {
if (!this.recorderManager || !this.recording) return
if (!this.recorderManager || this.recordPhase === 'idle') return
this.voiceCancelling = true
this.recording = false
if (this.recordPhase === 'recording') {
this.recorderManager.stop()
} else {
this.pendingStop = true
}
},
async uploadAndSendVoice(filePath, duration, fileSize) {
if (!filePath || this.uploadingVoice) return
if (!this.canSendMedia()) return
const mediaDuration = Math.min(duration, 60)
const clientMsgId = this.pushLocalMessage(filePath, 4, {
mediaDuration,
mediaSize: fileSize,
mediaFormat: 'mp3'
})
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' })
const voiceUrl = this.extractUploadUrl(await tui.uploadFile('/upload/file', filePath))
if (!voiceUrl) {
this.markSendFailed(clientMsgId, '语音上传失败,请稍后再试')
return
}
this.sendChatContent(voiceUrl, 4, {
mediaDuration: Math.min(duration, 60),
this.persistMessage({
roomId: Number(this.roomId),
clientMsgId,
messageType: 4,
content: voiceUrl,
mediaDuration,
mediaSize: fileSize,
mediaFormat: 'mp3',
mediaCheckUrl: voiceUrl,
mediaCheckType: 1,
mediaAuditStatus: 1
mediaCheckType: 1
})
} catch (e) {
this.markSendFailed(clientMsgId, '语音上传失败,请稍后再试')
} finally {
this.uploadingVoice = false
}
@ -994,6 +1164,17 @@
if (!images.length) return
uni.previewImage({ urls: images, current: images[index] })
},
goTargetSpace() {
const userId = this.targetProfile.userId || this.targetUserId
if (!userId) return
this.showTargetProfile = false
const name = encodeURIComponent(this.targetProfile.anonymousName || this.companion.name || 'TA')
const avatar = encodeURIComponent(this.targetProfile.avatarText || this.companion.avatar || 'TA')
const avatarUrl = encodeURIComponent(this.targetProfile.avatarUrl || this.companion.avatarUrl || '')
const mode = this.targetProfile.currentMode || this.roomMode || 'i'
// roomId userId
uni.navigateTo({ url: `/package1/ieBrowser/space?userId=${userId}&roomId=${this.roomId || ''}&name=${name}&avatar=${avatar}&avatarUrl=${avatarUrl}&mode=${mode}` })
},
async openTargetProfile() {
await this.loadTargetProfile()
if (!this.targetProfile || !this.targetProfile.userId) {
@ -1200,8 +1381,8 @@
}
.room-page {
min-height: 100vh;
padding: 0 24rpx 176rpx;
height: 100vh;
padding: 0 24rpx;
box-sizing: border-box;
color: #161b2e;
background:
@ -1210,6 +1391,8 @@
linear-gradient(180deg, #fbfdff 0%, #eef4ff 64%, #fff4e8 100%);
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
}
.room-page::before {
@ -1246,6 +1429,7 @@
}
.nav {
flex-shrink: 0;
position: relative;
z-index: 12;
min-height: 86rpx;
@ -1318,6 +1502,7 @@
}
.timer-card {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
@ -1327,6 +1512,7 @@
}
.radio-room {
flex-shrink: 0;
position: relative;
z-index: 2;
display: flex;
@ -1442,13 +1628,21 @@
font-size: 22rpx;
}
.timer-info {
flex: 1;
min-width: 0;
margin-right: 16rpx;
}
.timer-title {
margin-top: 8rpx;
font-size: 27rpx;
font-weight: 800;
font-size: 18rpx;
font-weight: 600;
line-height: 36rpx;
}
.timer-pill {
flex-shrink: 0;
width: 112rpx;
height: 60rpx;
border-radius: 999rpx;
@ -1460,6 +1654,16 @@
font-weight: 800;
}
.timer-pill.streak {
color: #ff6a3c;
background: rgba(255, 138, 84, 0.14);
border: 1rpx solid rgba(255, 138, 84, 0.3);
}
.timer-pill.expiring {
animation: streakBlink 1.2s ease-in-out infinite;
}
.topic-card {
margin-top: 24rpx;
padding: 26rpx;
@ -1474,14 +1678,20 @@
}
.messages {
flex: 1;
min-height: 0;
position: relative;
z-index: 1;
height: 64vh;
padding-top: 22rpx;
padding-bottom: 24rpx;
box-sizing: border-box;
}
.message-bottom-spacer {
width: 100%;
flex-shrink: 0;
}
.load-more {
width: 360rpx;
margin: 0 auto 24rpx;
@ -1566,30 +1776,36 @@
background: #dffef4;
}
.msg-body {
display: flex;
flex-direction: column;
align-items: flex-start;
max-width: 520rpx;
}
.msg-body.mine {
align-items: flex-end;
}
.bubble.muted {
background: rgba(223, 254, 244, 0.88);
opacity: .62;
}
.bubble.blocked {
opacity: .5;
opacity: .45;
filter: grayscale(1);
}
.audit-tip {
align-self: flex-end;
margin: 0 12rpx 0 0;
padding: 6rpx 14rpx;
border-radius: 999rpx;
color: rgba(22, 27, 46, .42);
background: rgba(255, 255, 255, .58);
font-size: 19rpx;
line-height: 28rpx;
}
.blocked-tip {
margin-top: 10rpx;
padding: 8rpx 18rpx;
border-radius: 999rpx;
color: #e85d75;
background: rgba(255, 228, 236, .82);
font-size: 20rpx;
line-height: 30rpx;
max-width: 460rpx;
}
.image-bubble {
@ -1707,7 +1923,7 @@
left: 28rpx;
right: 28rpx;
bottom: 132rpx;
z-index: 10;
z-index: 13;
display: flex;
overflow-x: auto;
white-space: nowrap;
@ -1733,22 +1949,6 @@
border: 1rpx solid rgba(122, 77, 255, 0.22);
}
.streak-pill {
flex-shrink: 0;
margin-right: 8rpx;
padding: 6rpx 16rpx;
border-radius: 999rpx;
font-size: 23rpx;
font-weight: 800;
color: #ff6a3c;
background: rgba(255, 138, 84, 0.14);
border: 1rpx solid rgba(255, 138, 84, 0.3);
}
.streak-pill.expiring {
animation: streakBlink 1.2s ease-in-out infinite;
}
@keyframes streakBlink {
0%, 100% { opacity: 1; }
50% { opacity: 0.45; }
@ -1883,7 +2083,7 @@
left: 24rpx;
right: 24rpx;
bottom: 30rpx;
z-index: 10;
z-index: 14;
height: 88rpx;
padding: 10rpx;
box-sizing: border-box;
@ -2209,6 +2409,36 @@
box-shadow: 0 16rpx 42rpx rgba(96, 112, 160, 0.12);
}
.profile-space-entry {
display: flex;
align-items: center;
margin-top: 22rpx;
padding: 20rpx 24rpx;
border-radius: 22rpx;
background: rgba(139, 124, 255, 0.08);
border: 1rpx solid rgba(139, 124, 255, 0.14);
color: #5a55c8;
font-size: 25rpx;
font-weight: 700;
}
.profile-space-entry:active {
transform: scale(.98);
background: rgba(139, 124, 255, 0.14);
}
.profile-space-icon {
margin-right: 12rpx;
font-size: 30rpx;
}
.profile-space-arrow {
flex: 1;
text-align: right;
font-size: 36rpx;
color: rgba(90, 85, 200, 0.5);
}
.profile-close {
height: 82rpx;
line-height: 82rpx;

130
package1/ieBrowser/index.vue

@ -23,9 +23,11 @@
此刻有 <text class="hero-num">{{ displayAwakeCount }}</text> 个灵魂正在这片星系漂流
</view>
<!-- <view class="hero-statement second"></view> -->
<view class="hero-live">
<view class="hero-live daily-live" v-if="dailyQuestion.exists" @tap="goDailyQuestion">
<text class="live-dot"></text>
<text>{{ waitingCount }} 个人正在等一句话</text>
<text class="daily-live-tag">同频一题</text>
<text class="daily-live-text">{{ dailyQuestion.content }}</text>
<text class="daily-live-go">{{ dailyQuestion.answered ? '看答案' : '去回答' }} </text>
</view>
<view class="hero-profile" @tap="goUniverse">
<image class="profile-avatar-img" v-if="profile.avatarUrl" :src="profile.avatarUrl" mode="aspectFill"></image>
@ -96,6 +98,7 @@
</view>
</view>
<view class="home-action-panel">
<view class="play-deck">
<view class="ticket" @tap="showTicketTip">
<view class="ticket-left">
@ -115,10 +118,8 @@
</view>
</view>
<view class="daily-q-bar" v-if="dailyQuestion.exists" @tap="goDailyQuestion">
<view class="daily-q-tag">同频一题</view>
<view class="daily-q-text">{{ dailyQuestion.content }}</view>
<view class="daily-q-go">{{ dailyQuestion.answered ? '看答案' : '去回答' }} </view>
<view class="daily-q-bar mood-copy-bar">
<text>{{ displayMoodCopy }}</text><text class="typing-cursor" v-if="displayMoodCopy.length < activeMood.copy.length"></text>
</view>
<view class="mood-strip">
@ -129,8 +130,6 @@
<text class="orb-label">{{ item.label }}</text>
</view>
</view>
<view class="say-bubble">
<text>{{ displayMoodCopy }}</text><text class="typing-cursor" v-if="displayMoodCopy.length < activeMood.copy.length"></text>
</view>
</view>
@ -801,7 +800,7 @@
.ie-page {
position: relative;
min-height: 100vh;
padding: 0 32rpx 150rpx;
padding: 0 32rpx 230rpx;
box-sizing: border-box;
color: #161b2e;
overflow: hidden;
@ -972,6 +971,7 @@
.hero-live {
display: inline-flex;
align-items: center;
max-width: 100%;
margin-top: 12rpx;
padding: 8rpx 22rpx;
border-radius: 999rpx;
@ -984,15 +984,44 @@
font-weight: 600;
}
.daily-live {
box-sizing: border-box;
gap: 10rpx;
padding-right: 18rpx;
}
.live-dot {
width: 14rpx;
height: 14rpx;
margin-right: 12rpx;
border-radius: 50%;
background: #2bd9a4;
animation: liveDotPulse 1.8s ease-out infinite;
}
.daily-live-tag,
.daily-live-go {
flex-shrink: 0;
font-weight: 900;
}
.daily-live-tag {
color: #6c5ce7;
}
.daily-live-text {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: rgba(22, 27, 46, .66);
}
.daily-live-go {
color: #8b7cff;
font-size: 21rpx;
}
.hero-profile {
display: flex;
align-items: center;
@ -1327,25 +1356,38 @@
animation: ctaBreath 2.2s ease-in-out infinite;
}
/* ============ 漂流票 + 想遇见谁 ============ */
.play-deck {
/* ============ 底部功能面板 ============ */
.home-action-panel {
position: relative;
z-index: 4;
margin-top: 26rpx;
padding: 14rpx 14rpx 18rpx;
border-radius: 32rpx;
background:
linear-gradient(180deg, rgba(255, 255, 255, .82), rgba(255, 255, 255, .68)),
radial-gradient(circle at 16% 0%, rgba(169, 255, 231, .28), transparent 220rpx),
radial-gradient(circle at 88% 22%, rgba(162, 155, 254, .2), transparent 240rpx);
border: 1rpx solid rgba(255, 255, 255, .9);
backdrop-filter: blur(22rpx);
box-shadow: 0 22rpx 70rpx rgba(96, 112, 160, .14), inset 0 1rpx 0 rgba(255, 255, 255, .92);
}
.play-deck {
display: flex;
align-items: stretch;
gap: 20rpx;
margin-top: 24rpx;
top: 12rpx;
gap: 12rpx;
}
.ticket {
flex: 1.3;
display: flex;
align-items: stretch;
min-height: 100rpx;
border-radius: 26rpx;
min-height: 94rpx;
border-radius: 24rpx;
overflow: hidden;
background: #ffffff;
box-shadow: 0 14rpx 36rpx rgba(96, 112, 160, .14);
box-shadow: 0 12rpx 30rpx rgba(96, 112, 160, .12);
transition: transform .16s ease;
}
@ -1358,7 +1400,7 @@
display: flex;
align-items: center;
gap: 10rpx;
padding: 0 14rpx 0 22rpx;
padding: 0 12rpx 0 18rpx;
background: linear-gradient(135deg, #dffef4, #aef5dd);
}
@ -1368,9 +1410,10 @@
.ticket-label {
color: #0e4a35;
font-size: 23rpx;
font-size: 22rpx;
font-weight: 900;
letter-spacing: 1rpx;
white-space: nowrap;
}
.ticket-divider {
@ -1399,11 +1442,12 @@
align-items: center;
justify-content: center;
gap: 6rpx;
padding: 0 26rpx;
min-width: 106rpx;
padding: 0 16rpx;
}
.ticket-num {
font-size: 48rpx;
font-size: 46rpx;
font-weight: 900;
color: #6c5ce7;
background: linear-gradient(120deg, #6c5ce7, #00b894);
@ -1426,12 +1470,12 @@
align-items: center;
justify-content: center;
gap: 4rpx;
min-height: 100rpx;
border-radius: 26rpx;
min-height: 94rpx;
border-radius: 24rpx;
overflow: hidden;
color: #fff;
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
box-shadow: 0 16rpx 40rpx rgba(108, 92, 231, .32);
box-shadow: 0 14rpx 34rpx rgba(108, 92, 231, .28);
transition: transform .16s ease;
animation: entryWiggle 5s ease-in-out infinite;
}
@ -1467,19 +1511,29 @@
z-index: 4;
display: flex;
align-items: center;
gap: 14rpx;
margin-top: 24rpx;
padding: 16rpx 22rpx;
gap: 12rpx;
margin-top: 28rpx;
padding: 22rpx 18rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, .66);
background: rgba(255, 255, 255, .76);
border: 1rpx solid rgba(255, 255, 255, .8);
box-shadow: 0 14rpx 32rpx rgba(96, 112, 160, .12);
box-shadow: 0 10rpx 26rpx rgba(96, 112, 160, .1);
}
.daily-q-bar:active {
transform: scale(.98);
}
.mood-copy-bar {
justify-content: center;
min-height: 58rpx;
color: rgba(22, 27, 46, .62);
font-size: 23rpx;
line-height: 32rpx;
text-align: center;
font-weight: 800;
}
.daily-q-tag {
flex-shrink: 0;
padding: 6rpx 16rpx;
@ -1510,7 +1564,8 @@
.mood-strip {
position: relative;
z-index: 4;
margin-top: 58rpx;
margin-top: 22rpx;
padding: 2rpx 4rpx 0;
}
.strip-tag {
@ -1529,21 +1584,20 @@
.mood-orbs {
display: flex;
justify-content: space-between;
margin-top: 22rpx;
padding: 0 10rpx;
padding: 0 8rpx;
}
.mood-orb {
display: flex;
flex-direction: column;
align-items: center;
gap: 10rpx;
gap: 8rpx;
}
.orb-face {
position: relative;
width: 104rpx;
height: 104rpx;
width: 94rpx;
height: 94rpx;
border-radius: 50%;
display: flex;
align-items: center;
@ -1574,7 +1628,7 @@
}
.orb-emoji {
font-size: 48rpx;
font-size: 44rpx;
line-height: 1;
}
@ -1598,11 +1652,11 @@
position: relative;
width: fit-content;
max-width: 620rpx;
margin: 40rpx auto 0;
margin: 24rpx auto 0;
display: flex;
align-items: center;
justify-content: center;
padding: 14rpx 28rpx;
padding: 12rpx 24rpx;
border-radius: 999rpx;
color: rgba(22, 27, 46, .58);
background: rgba(255, 255, 255, .76);

46
package1/ieBrowser/messages.vue

@ -9,15 +9,21 @@
</view>
<view class="head">
<view class="title">消息</view>
<view class="sub">这里不是聊天列表只放未完成的陪伴</view>
<view class="sub">聊天列表未完成的陪伴</view>
</view>
<view class="room-card" v-for="item in requests" :key="item.id" @tap="openRecord(item)" @longpress="confirmDelete(item)">
<view class="avatar-wrap">
<image class="avatar-img" v-if="item.avatarUrl" :src="item.avatarUrl" mode="aspectFill"></image>
<view class="avatar" v-else>{{ item.avatar }}</view>
<view class="unread-badge" v-if="item.unreadCount > 0">{{ item.unreadCount > 99 ? '99+' : item.unreadCount }}</view>
</view>
<view class="main">
<view class="name">{{ item.name }}</view>
<view class="desc">{{ item.desc }}</view>
</view>
<view class="streak-pill" v-if="item.streakDays > 0" :class="{ expiring: item.streakExpiring }" @tap.stop="showStreakTip(item)">
🔥x{{ item.streakDays }}
</view>
<view class="tag soft" v-if="item.remeet">再遇见</view>
</view>
<view class="empty" v-if="!loading && !requests.length">没有未完成的关系也是一种轻松</view>
@ -85,6 +91,7 @@
if (this.unreadTimer) return
this.unreadTimer = setInterval(() => {
this.refreshUnreadCount()
this.refreshRecords()
}, 15000)
},
stopUnreadTimer() {
@ -107,10 +114,27 @@
name: item.anonymousName || '半匿名漂流者',
avatar: item.avatarText || ((item.currentMode || item.mode) === 'e' ? '光' : '月'),
avatarUrl: item.avatarUrl || '',
unreadCount: Number(item.unreadCount) || 0,
desc: (item.unreadCount > 0 ? `${item.unreadCount} 条未读 · ` : '') + (item.intro || (remeet ? '可以从聊天记录继续这段对话。' : (item.summary || '这段聊天已保留在记录里。'))),
streakDays: Number(item.streakDays) || 0,
streakExpiring: !!item.streakExpiring,
streakRewardInterval: item.streakRewardInterval || 7,
remeet
}
},
// /
async refreshRecords() {
if (this.loading || this.deleting) return
try {
const page = await pageIeRecords(1, this.pageSize)
const list = page && page.records ? page.records.map(this.normalizeRecord) : []
if (!list.length) return
const freshIds = new Set(list.map(item => item.id))
this.requests = list.concat(this.requests.filter(item => !freshIds.has(item.id)))
} catch (e) {
//
}
},
async loadRecords() {
if (this.loading || !this.hasMore) return
this.loading = true
@ -125,7 +149,7 @@
this.loading = false
}
},
backHome() { uni.redirectTo({ url: '/pages/index/index' }) },
backHome() { uni.switchTab({ url: '/pages/index/index' }) },
confirmDelete(item) {
if (!item || !item.id) return
this.deleteTarget = item
@ -161,6 +185,13 @@
'&avatarUrl=' + encodeURIComponent(item.avatarUrl || '') +
'&quote=' + encodeURIComponent(item.desc || '')
})
},
showStreakTip(item) {
if (!item || !item.streakDays) return
const tip = item.streakExpiring
? `火花 x${item.streakDays},今天还没续上,互发一句就不会熄灭`
: `连续 ${item.streakDays} 天互相陪伴,每满 ${item.streakRewardInterval || 7} 天双方各得一次匹配机会`
uni.showToast({ title: tip, icon: 'none', duration: 2600 })
}
}
}
@ -169,17 +200,19 @@
<style lang="scss" scoped>
page { background: #f7f9ff; }
.messages-page { min-height: 100vh; padding: 0 30rpx 230rpx; box-sizing: border-box; color: #151a2d; background: radial-gradient(circle at 18% 8%, rgba(169,255,231,.42), transparent 280rpx), linear-gradient(180deg, #fbfdff, #eef4ff); }
.float-bar { position: relative; z-index: 5; display: flex; align-items: center; justify-content: space-between; margin-top: 14rpx; }
.float-bar { position: relative; z-index: 5; display: flex; align-items: center; justify-content: space-between; }
.home-back { display: flex; align-items: center; height: 58rpx; padding: 0 22rpx 0 12rpx; border: 1rpx solid rgba(255,255,255,.88); border-radius: 999rpx; color: rgba(22,27,46,.66); background: rgba(255,255,255,.66); backdrop-filter: blur(20rpx); box-shadow: 0 14rpx 36rpx rgba(96,112,160,.12), inset 0 1rpx 0 rgba(255,255,255,.95); font-size: 23rpx; font-weight: 800; }
.home-back:active { transform: scale(.95); background: rgba(169,255,231,.7); }
.home-back-icon { margin-right: 6rpx; font-size: 40rpx; line-height: 50rpx; font-weight: 400; }
.home-back-icon { margin-right: 6rpx;padding-bottom: 6rpx; font-size: 40rpx; line-height: 50rpx; font-weight: 400; }
.head { padding-top: 10rpx; }
.title { font-size: 52rpx; font-weight: 800; }
.sub { margin-top: 12rpx; color: rgba(21,26,45,.5); font-size: 25rpx; }
.room-card { display: flex; align-items: center; margin-top: 26rpx; padding: 28rpx; border-radius: 36rpx; background: rgba(255,255,255,.64); border: 1rpx solid rgba(255,255,255,.86); box-shadow: 0 22rpx 60rpx rgba(96,112,160,.12); }
.room-card.active { background: rgba(17,22,42,.92); color: #fff; }
.pulse, .avatar, .avatar-img { width: 78rpx; height: 78rpx; margin-right: 20rpx; border-radius: 50%; }
.avatar-wrap { position: relative; flex-shrink: 0; width: 78rpx; height: 78rpx; margin-right: 20rpx; }
.pulse, .avatar, .avatar-img { width: 78rpx; height: 78rpx; border-radius: 50%; }
.avatar { text-align: center; line-height: 78rpx; background: #a9ffe7; color: #11162a; font-weight: 800; }
.unread-badge { position: absolute; right: -8rpx; top: -8rpx; min-width: 30rpx; height: 30rpx; line-height: 30rpx; padding: 0 8rpx; border-radius: 999rpx; text-align: center; color: #fff; background: #ff8fa3; border: 3rpx solid rgba(255,255,255,.96); box-shadow: 0 8rpx 18rpx rgba(255,143,163,.26); font-size: 18rpx; font-weight: 800; box-sizing: border-box; }
.pulse { animation: breath 2.4s ease-in-out infinite; }
.main { flex: 1; }
.name { font-size: 29rpx; font-weight: 800; }
@ -187,6 +220,8 @@
.active .desc { color: rgba(255,255,255,.58); }
.tag { padding: 8rpx 16rpx; border-radius: 999rpx; color: #11162a; background: #a9ffe7; font-size: 20rpx; }
.tag.soft { color: #7771d8; background: rgba(119,113,216,.1); }
.streak-pill { flex-shrink: 0; margin-left: 12rpx; margin-right: 10rpx; padding: 8rpx 16rpx; border-radius: 999rpx; font-size: 22rpx; font-weight: 800; color: #ff6a3c; background: rgba(255,138,84,.14); border: 1rpx solid rgba(255,138,84,.3); }
.streak-pill.expiring { animation: streakBlink 1.2s ease-in-out infinite; }
.empty { margin-top: 42rpx; text-align: center; color: rgba(21,26,45,.36); font-size: 23rpx; }
.delete-mask { position: fixed; inset: 0; z-index: 99; display: flex; align-items: flex-end; justify-content: center; padding: 0 30rpx 54rpx; box-sizing: border-box; background: rgba(16,22,40,.28); backdrop-filter: blur(12rpx); }
.delete-dialog { width: 100%; padding: 42rpx 34rpx 30rpx; border-radius: 42rpx; box-sizing: border-box; text-align: center; background: linear-gradient(180deg, rgba(255,255,255,.96), rgba(248,251,255,.92)); border: 1rpx solid rgba(255,255,255,.9); box-shadow: 0 34rpx 90rpx rgba(42,50,86,.22); animation: dialogUp .18s ease-out; }
@ -200,5 +235,6 @@
.delete-btn.danger { color: #fff; background: linear-gradient(135deg, #ff8ba0, #e85d75); box-shadow: 0 18rpx 38rpx rgba(232,93,117,.22); }
.delete-btn.disabled { opacity: .62; }
@keyframes breath { 0%, 100% { transform: scale(.9); opacity: .65; } 50% { transform: scale(1.04); opacity: 1; } }
@keyframes streakBlink { 0%, 100% { opacity: 1; } 50% { opacity: .45; } }
@keyframes dialogUp { from { transform: translateY(36rpx); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
</style>

414
package1/ieBrowser/mySpace.vue

@ -0,0 +1,414 @@
<template>
<view class="my-space-page">
<view class="top-safe" :style="{ height: menuButtonInfo.top + 'px' }"></view>
<view class="float-bar">
<view class="home-back" @tap="back">
<text class="home-back-icon"></text>
<text>返回资料</text>
</view>
</view>
<view class="space-hero">
<image class="hero-avatar-img" v-if="profile.avatarUrl" :src="profile.avatarUrl" mode="aspectFill"></image>
<view class="hero-avatar" v-else>{{ profile.avatarText || '' }}</view>
<view class="hero-main">
<view class="hero-title-row">
<view class="hero-title">我的个人空间</view>
<view class="publish-btn" @tap="openComposer">+ 发动态</view>
</view>
<view class="hero-sub">{{ profile.anonymousName || '半匿名漂流者' }} · {{ profile.currentMode === 'e' ? '轻轻热闹' : '安静陪伴' }}</view>
</view>
</view>
<view class="moment-empty" v-if="!moments.length && !loadingMoments">还没有动态发一条让同频的人认识你</view>
<view class="moment-card" v-for="item in moments" :key="item.id">
<view class="moment-top">
<text class="moment-time">{{ momentTime(item.createTime) }}</text>
<text class="moment-state blocked" v-if="item.localState === 'failed'">{{ item.failTip || '发布失败' }}</text>
<text class="moment-state auditing" v-else-if="item.status === 0">审核中</text>
<text class="moment-state blocked" v-else-if="item.status === 2">未通过审核</text>
<text class="moment-del" @tap="removeMoment(item)">删除</text>
</view>
<view class="moment-text" v-if="item.content">{{ item.content }}</view>
<view class="moment-grid" v-if="item.imageList && item.imageList.length" :class="'g' + item.imageList.length">
<image v-for="(img, idx) in item.imageList" :key="idx" :src="img" mode="aspectFill" @tap="previewMomentImages(item, idx)"></image>
</view>
<view class="moment-video" v-if="item.videoUrl" @tap="playMomentVideo(item)">
<image class="moment-video-poster" v-if="item.videoPoster" :src="item.videoPoster" mode="aspectFill"></image>
<view class="moment-video-holder" v-else></view>
<view class="moment-play"></view>
</view>
</view>
<view class="moment-more" v-if="loadingMoments">加载中...</view>
<view class="moment-more" v-else-if="hasMoreMoments && moments.length" @tap="loadMoments(false)">查看更早的动态</view>
<view class="moment-more" v-else-if="!hasMoreMoments && moments.length">没有更多动态了</view>
<view class="composer-mask" v-if="showComposer" @tap="closeComposer">
<view class="composer" @tap.stop>
<view class="composer-title">发动态</view>
<textarea class="composer-input" v-model="composeText" maxlength="1000"
placeholder="此刻想说点什么..." :show-confirm-bar="false"></textarea>
<view class="composer-grid">
<view class="composer-thumb" v-for="(img, idx) in composeImages" :key="idx" @tap="retryComposeImage(img)">
<image :src="img.local" mode="aspectFill"></image>
<view class="thumb-state" v-if="img.status === 'uploading'">上传中</view>
<view class="thumb-state failed" v-else-if="img.status === 'failed'">失败·点我重试</view>
<view class="thumb-del" @tap.stop="removeComposeImage(idx)">×</view>
</view>
<view class="composer-thumb" v-if="composeVideo" @tap="retryComposeVideo">
<image v-if="composeVideo.poster" :src="composeVideo.poster" mode="aspectFill"></image>
<view class="thumb-video-mark"> 视频</view>
<view class="thumb-state" v-if="composeVideo.status === 'uploading'">上传中</view>
<view class="thumb-state failed" v-else-if="composeVideo.status === 'failed'">失败·点我重试</view>
<view class="thumb-del" @tap.stop="composeVideo = null">×</view>
</view>
<view class="composer-add" v-if="canAddMedia" @tap="chooseMomentMedia"></view>
</view>
<view class="composer-tip">最多 5 张图片 1 个视频 · 内容需通过审核</view>
<view class="composer-submit" :class="{ disabled: publishing }" @tap="submitMoment">发布</view>
</view>
</view>
</view>
</template>
<script>
import { getIeProfile, pageIeMoments, publishIeMoment, deleteIeMoment } from '@/common/ieApi.js'
import tui from '@/common/httpRequest.js'
export default {
data() {
return {
menuButtonInfo: { top: 44 },
profile: {},
moments: [],
momentPage: 1,
hasMoreMoments: true,
loadingMoments: false,
showComposer: false,
composeText: '',
composeImages: [],
composeVideo: null,
publishing: false
}
},
computed: {
canAddMedia() {
if (this.composeVideo) return false
return this.composeImages.length < 5
}
},
onLoad() {
if (uni.getMenuButtonBoundingClientRect) this.menuButtonInfo = uni.getMenuButtonBoundingClientRect()
this.loadProfile()
this.loadMoments(true)
},
onReachBottom() {
this.loadMoments(false)
},
async onPullDownRefresh() {
try {
await Promise.all([
this.loadProfile(),
this.loadMoments(true, true)
])
} finally {
uni.stopPullDownRefresh()
}
},
methods: {
back() {
uni.navigateBack({ fail: () => uni.redirectTo({ url: '/package1/ieBrowser/universe' }) })
},
async loadProfile() {
const profile = await getIeProfile()
if (profile) this.profile = profile
},
async loadMoments(reset, force = false) {
if (this.loadingMoments && !force) return
if (!reset && !this.hasMoreMoments) return
this.loadingMoments = true
try {
const page = reset ? 1 : this.momentPage
const result = await pageIeMoments(null, page, 10)
const records = (result && result.records) || []
this.moments = reset ? records : this.moments.concat(records)
this.hasMoreMoments = records.length >= 10
this.momentPage = page + 1
} finally {
this.loadingMoments = false
}
},
momentTime(timeStr) {
if (!timeStr) return ''
const time = new Date(String(timeStr).replace(/-/g, '/')).getTime()
if (!time) return ''
const diff = Date.now() - time
if (diff < 60000) return '刚刚'
if (diff < 3600000) return Math.floor(diff / 60000) + ' 分钟前'
if (diff < 86400000) return Math.floor(diff / 3600000) + ' 小时前'
if (diff < 172800000) return '昨天'
const d = new Date(time)
return (d.getMonth() + 1) + '月' + d.getDate() + '日'
},
previewMomentImages(item, index) {
uni.previewImage({ urls: item.imageList || [], current: item.imageList[index] })
},
playMomentVideo(item) {
if (item.status === 0) {
uni.showToast({ title: '视频审核中,通过后对方可见', icon: 'none' })
}
if (!item.videoUrl) return
uni.navigateTo({ url: '/package1/ieBrowser/videoPreview?url=' + encodeURIComponent(item.videoUrl) })
},
removeMoment(item) {
if (String(item.id).indexOf('local-') === 0) {
this.moments = this.moments.filter(m => m.id !== item.id)
return
}
uni.showModal({
title: '删除动态',
content: '删除后无法恢复,确定删除吗?',
success: async (res) => {
if (!res.confirm) return
await deleteIeMoment(item.id)
this.moments = this.moments.filter(m => m.id !== item.id)
}
})
},
openComposer() {
this.showComposer = true
},
closeComposer() {
this.showComposer = false
},
removeComposeImage(index) {
this.composeImages.splice(index, 1)
},
addComposeImages(paths) {
const items = (paths || [])
.filter(p => !!p)
.slice(0, 5 - this.composeImages.length)
.map(p => ({ local: p, url: '', status: 'uploading' }))
if (!items.length) return
this.composeImages = this.composeImages.concat(items)
items.forEach(item => this.uploadComposeImage(item))
},
async uploadComposeImage(item) {
item.status = 'uploading'
try {
item.url = await this.silentUpload(item.local)
item.status = 'done'
} catch (e) {
item.status = 'failed'
}
},
retryComposeImage(item) {
if (item.status === 'failed') this.uploadComposeImage(item)
},
async uploadComposeVideo() {
const video = this.composeVideo
if (!video) return
video.status = 'uploading'
try {
video.url = await this.silentUpload(video.local)
if (video.poster) {
try { video.posterUrl = await this.silentUpload(video.poster) } catch (e) { video.posterUrl = '' }
}
video.status = 'done'
} catch (e) {
video.status = 'failed'
}
},
retryComposeVideo() {
if (this.composeVideo && this.composeVideo.status === 'failed') this.uploadComposeVideo()
},
chooseMomentMedia() {
const hasImages = this.composeImages.length > 0
if (!uni.chooseMedia) {
uni.chooseImage({
count: 5 - this.composeImages.length,
success: (res) => {
this.addComposeImages(res.tempFilePaths || [])
}
})
return
}
uni.chooseMedia({
count: hasImages ? 5 - this.composeImages.length : 5,
mediaType: hasImages ? ['image'] : ['image', 'video'],
maxDuration: 60,
success: (res) => {
const files = res.tempFiles || []
if (!files.length) return
if (res.type === 'video') {
const file = files[0]
if (file.size > 100 * 1024 * 1024) {
uni.showToast({ title: '视频不能超过100MB,请先剪辑', icon: 'none' })
return
}
if (file.duration && file.duration > 61) {
uni.showToast({ title: '视频不能超过60秒', icon: 'none' })
return
}
this.composeVideo = {
local: file.tempFilePath,
poster: file.thumbTempFilePath || '',
size: file.size,
url: '',
posterUrl: '',
status: 'uploading'
}
this.uploadComposeVideo()
return
}
const oversize = files.find(f => f.size > 10 * 1024 * 1024)
if (oversize) {
uni.showToast({ title: '单张图片不能超过10MB', icon: 'none' })
return
}
this.addComposeImages(files.map(f => f.tempFilePath))
}
})
},
silentUpload(filePath) {
return new Promise((resolve, reject) => {
uni.uploadFile({
url: tui.interfaceUrl() + '/upload/file',
filePath,
name: 'file',
timeout: 300000,
header: { accessToken: uni.getStorageSync('hiver_token') },
success: (res) => {
try {
const d = JSON.parse(String(res.data || '{}').replace(/\ufeff/g, ''))
const fileObj = d.result !== undefined ? d.result : d.data
const url = typeof fileObj === 'string' ? fileObj : (fileObj && (fileObj.url || fileObj.fileUrl || fileObj.path || fileObj.fullPath))
if (d.code % 100 === 0 && url) resolve(url)
else reject(new Error(d.message || '上传失败'))
} catch (e) {
reject(e)
}
},
fail: () => reject(new Error('上传失败'))
})
})
},
async waitComposeUploads(images, video) {
const uploading = () => images.some(item => item.status === 'uploading') ||
(video && video.status === 'uploading')
while (uploading()) {
await new Promise(resolve => setTimeout(resolve, 500))
}
if (images.some(item => item.status !== 'done')) throw new Error('图片上传失败')
if (video && video.status !== 'done') throw new Error('视频上传失败')
},
async submitMoment() {
if (this.publishing) return
const content = this.composeText.trim()
if (!content && !this.composeImages.length && !this.composeVideo) {
uni.showToast({ title: '写点什么或选张图片吧', icon: 'none' })
return
}
const images = this.composeImages
const video = this.composeVideo
if (images.some(item => item.status === 'failed') || (video && video.status === 'failed')) {
uni.showToast({ title: '有图片/视频上传失败,点缩略图重试', icon: 'none' })
return
}
this.publishing = true
const localCard = {
id: 'local-' + Date.now(),
content,
imageList: images.map(item => item.local),
videoUrl: video ? video.local : '',
videoPoster: video ? video.poster : '',
status: 1,
localState: 'publishing',
failTip: '',
createTime: this.formatNow()
}
this.moments.unshift(localCard)
this.showComposer = false
this.composeText = ''
this.composeImages = []
this.composeVideo = null
this.publishing = false
uni.showToast({ title: '已发布', icon: 'none' })
try {
await this.waitComposeUploads(images, video)
const payload = { content, images: images.map(item => item.url) }
if (video) {
payload.videoUrl = video.url
payload.videoPoster = video.posterUrl || ''
}
const moment = await publishIeMoment(payload)
if (!moment) throw new Error('发布失败,请稍后再试')
const index = this.moments.findIndex(m => m.id === localCard.id)
if (index >= 0) {
moment.imageList = localCard.imageList.length ? localCard.imageList : moment.imageList
moment.videoPoster = localCard.videoPoster || moment.videoPoster
this.moments.splice(index, 1, moment)
}
} catch (e) {
localCard.localState = 'failed'
localCard.failTip = (e && e.message) || '发布失败'
uni.showToast({ title: localCard.failTip, icon: 'none' })
}
},
formatNow() {
const d = new Date()
const pad = n => (n < 10 ? '0' + n : '' + n)
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' +
pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds())
}
}
}
</script>
<style lang="scss" scoped>
page { background: #f7f9ff; }
.my-space-page { min-height: 100vh; padding: 0 32rpx 80rpx; box-sizing: border-box; color: #161b2e; background: radial-gradient(circle at 50% 8%, rgba(169,255,231,.36), transparent 320rpx), radial-gradient(circle at 86% 30%, rgba(255,184,209,.2), transparent 340rpx), linear-gradient(180deg, #fbfdff, #eef4ff 62%, #fff4e8); }
.float-bar { position: relative; z-index: 5; display: flex; align-items: center; }
.home-back { display: flex; align-items: center; height: 58rpx; padding: 0 22rpx 0 12rpx; border: 1rpx solid rgba(255,255,255,.88); border-radius: 999rpx; color: rgba(22,27,46,.66); background: rgba(255,255,255,.66); backdrop-filter: blur(20rpx); box-shadow: 0 14rpx 36rpx rgba(96,112,160,.12), inset 0 1rpx 0 rgba(255,255,255,.95); font-size: 23rpx; font-weight: 800; }
.home-back-icon { margin-right: 6rpx; padding-bottom: 6rpx; font-size: 40rpx; line-height: 50rpx; font-weight: 400; }
.publish-btn { flex-shrink: 0; height: 54rpx; line-height: 54rpx; padding: 0 20rpx; border-radius: 999rpx; color: #11162a; background: #a9ffe7; font-size: 22rpx; font-weight: 900; box-shadow: 0 12rpx 28rpx rgba(96,200,170,.24); }
.space-hero { display: flex; align-items: center; margin-top: 28rpx; padding: 30rpx; border-radius: 38rpx; background: rgba(255,255,255,.66); border: 1rpx solid rgba(255,255,255,.82); box-shadow: 0 22rpx 70rpx rgba(96,112,160,.14); }
.hero-avatar, .hero-avatar-img { flex-shrink: 0; width: 112rpx; height: 112rpx; margin-right: 24rpx; border-radius: 50%; }
.hero-avatar { line-height: 112rpx; text-align: center; color: #11162a; background: linear-gradient(145deg, #effffb, #a9ffe7); font-size: 38rpx; font-weight: 900; }
.hero-main { flex: 1; min-width: 0; }
.hero-title-row { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; }
.hero-title { font-size: 36rpx; font-weight: 900; }
.hero-sub { margin-top: 10rpx; color: rgba(22,27,46,.48); font-size: 23rpx; }
.moment-empty { margin-top: 60rpx; text-align: center; color: rgba(22,27,46,.4); font-size: 25rpx; }
.moment-card { margin-top: 24rpx; padding: 26rpx; border-radius: 28rpx; background: rgba(255,255,255,.85); border: 1rpx solid rgba(255,255,255,.9); box-shadow: 0 12rpx 32rpx rgba(96,112,160,.08); }
.moment-top { display: flex; align-items: center; }
.moment-time { flex: 1; color: rgba(22,27,46,.42); font-size: 21rpx; }
.moment-state { margin-right: 16rpx; padding: 4rpx 14rpx; border-radius: 999rpx; font-size: 20rpx; font-weight: 700; }
.moment-state.auditing { color: #b07b1f; background: rgba(255,196,87,.18); }
.moment-state.blocked { color: #d2486a; background: rgba(255,107,146,.14); }
.moment-del { color: rgba(22,27,46,.38); font-size: 22rpx; padding: 4rpx 8rpx; }
.moment-text { margin-top: 14rpx; font-size: 27rpx; line-height: 42rpx; word-break: break-all; }
.moment-grid { display: flex; flex-wrap: wrap; margin-top: 16rpx; }
.moment-grid image { width: 196rpx; height: 196rpx; margin: 0 10rpx 10rpx 0; border-radius: 16rpx; background: rgba(22,27,46,.05); }
.moment-grid.g1 image { width: 400rpx; height: 400rpx; border-radius: 22rpx; }
.moment-grid.g2 image { width: 300rpx; height: 300rpx; }
.moment-video { position: relative; margin-top: 16rpx; width: 440rpx; height: 290rpx; border-radius: 22rpx; overflow: hidden; background: #1a2034; }
.moment-video-poster, .moment-video-holder { width: 100%; height: 100%; }
.moment-play { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); width: 84rpx; height: 84rpx; line-height: 84rpx; text-align: center; border-radius: 50%; color: #fff; background: rgba(0,0,0,.45); font-size: 32rpx; }
.moment-more { margin-top: 26rpx; text-align: center; color: rgba(22,27,46,.36); font-size: 22rpx; padding: 12rpx 0; }
.composer-mask { position: fixed; inset: 0; z-index: 120; display: flex; align-items: flex-end; background: rgba(13,17,32,.5); backdrop-filter: blur(10rpx); }
.composer { width: 100%; padding: 36rpx 32rpx calc(40rpx + env(safe-area-inset-bottom)); border-radius: 40rpx 40rpx 0 0; background: #ffffff; box-sizing: border-box; }
.composer-title { font-size: 32rpx; font-weight: 800; text-align: center; }
.composer-input { width: 100%; height: 200rpx; margin-top: 24rpx; padding: 22rpx; border-radius: 22rpx; background: #f4f6fc; font-size: 27rpx; box-sizing: border-box; }
.composer-grid { display: flex; flex-wrap: wrap; margin-top: 20rpx; }
.composer-thumb { position: relative; width: 150rpx; height: 150rpx; margin: 0 14rpx 14rpx 0; border-radius: 16rpx; overflow: hidden; background: #1a2034; }
.composer-thumb image { width: 100%; height: 100%; }
.thumb-del { position: absolute; right: 0; top: 0; width: 40rpx; height: 40rpx; line-height: 36rpx; text-align: center; color: #fff; background: rgba(0,0,0,.55); border-radius: 0 0 0 16rpx; font-size: 26rpx; }
.thumb-video-mark { position: absolute; left: 0; bottom: 0; right: 0; padding: 4rpx 0; text-align: center; color: #fff; background: rgba(0,0,0,.45); font-size: 20rpx; }
.thumb-state { position: absolute; left: 0; top: 0; right: 0; bottom: 0; display: flex; align-items: center; justify-content: center; color: #fff; background: rgba(0,0,0,.42); font-size: 20rpx; }
.thumb-state.failed { color: #ffd9e2; background: rgba(160,30,60,.55); }
.composer-add { width: 150rpx; height: 150rpx; line-height: 146rpx; text-align: center; border-radius: 16rpx; border: 2rpx dashed rgba(22,27,46,.18); color: rgba(22,27,46,.34); font-size: 52rpx; box-sizing: border-box; }
.composer-tip { margin-top: 8rpx; color: rgba(22,27,46,.38); font-size: 21rpx; }
.composer-submit { margin-top: 28rpx; height: 92rpx; line-height: 92rpx; text-align: center; border-radius: 999rpx; color: #11162a; background: linear-gradient(135deg, #a9ffe7, #7be3c8); font-size: 30rpx; font-weight: 800; box-shadow: 0 14rpx 32rpx rgba(96,200,170,.32); }
.composer-submit.disabled { opacity: .6; }
</style>

39
package1/ieBrowser/profileSetup.vue

@ -84,7 +84,20 @@
<view class="actions">
<view class="ghost" @tap="prev" v-if="step > 1">上一步</view>
<view class="solid" @tap="next">{{ step === 5 ? '进入 i/e 此刻' : '继续' }}</view>
<view class="solid" :class="{ disabled: submitting }" @tap="next">{{ submitButtonText }}</view>
</view>
<view class="creating-mask" v-if="submitting">
<view class="creating-card">
<view class="creating-orbit">
<view class="creating-dot dot-a"></view>
<view class="creating-dot dot-b"></view>
<view class="creating-core">{{ form.currentMode }}</view>
</view>
<view class="creating-title">创建身份中</view>
<view class="creating-sub">资料正在进行内容审核请稍等一下</view>
<view class="creating-progress"><view></view></view>
</view>
</view>
</view>
</template>
@ -127,6 +140,10 @@
computed: {
avatarPreview() {
return (this.form.anonymousName || this.form.avatarText || '我').slice(0, 1)
},
submitButtonText() {
if (this.submitting) return this.step === 5 ? '创建身份中...' : '处理中...'
return this.step === 5 ? '进入 i/e 此刻' : '继续'
}
},
onLoad(options) {
@ -310,7 +327,6 @@
return
}
this.submitting = true
uni.showLoading({ title: '正在进入 i/e', mask: true })
try {
const areaInfo = this.getAreaInfo()
if (!areaInfo.regionId) {
@ -326,7 +342,6 @@
uni.redirectTo({ url: this.isEdit ? '/package1/ieBrowser/universe' : '/package1/ieBrowser/index' })
} finally {
this.submitting = false
uni.hideLoading()
}
},
back() {
@ -387,4 +402,22 @@
.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); }
.solid.disabled { opacity: .7; }
.creating-mask { position: fixed; inset: 0; z-index: 80; display: flex; align-items: center; justify-content: center; padding: 48rpx; box-sizing: border-box; background: rgba(20,26,46,.24); backdrop-filter: blur(18rpx); }
.creating-card { width: 100%; padding: 52rpx 40rpx 42rpx; border-radius: 44rpx; text-align: center; background: linear-gradient(180deg, rgba(255,255,255,.96), rgba(247,249,255,.9)); border: 1rpx solid rgba(255,255,255,.92); box-shadow: 0 34rpx 100rpx rgba(42,50,86,.22); box-sizing: border-box; }
.creating-orbit { position: relative; width: 132rpx; height: 132rpx; margin: 0 auto; border-radius: 50%; background: radial-gradient(circle, #effffb 0%, #a9ffe7 58%, rgba(169,255,231,.2) 100%); box-shadow: 0 20rpx 54rpx rgba(96,200,170,.26), inset 0 2rpx 0 rgba(255,255,255,.9); animation: creatingPulse 1.8s ease-in-out infinite; }
.creating-orbit::before { content: ''; position: absolute; inset: -18rpx; border-radius: 50%; border: 3rpx dashed rgba(108,105,216,.24); animation: creatingSpin 5s linear infinite; }
.creating-core { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); color: #151a2d; font-size: 48rpx; font-weight: 900; }
.creating-dot { position: absolute; width: 18rpx; height: 18rpx; border-radius: 50%; background: #8b7cff; box-shadow: 0 0 24rpx rgba(139,124,255,.45); }
.dot-a { right: 2rpx; top: 22rpx; animation: dotFloatA 1.7s ease-in-out infinite; }
.dot-b { left: 8rpx; bottom: 26rpx; background: #ff9fc1; animation: dotFloatB 1.9s ease-in-out infinite; }
.creating-title { margin-top: 34rpx; font-size: 36rpx; font-weight: 900; color: #151a2d; }
.creating-sub { margin-top: 12rpx; color: rgba(21,26,45,.52); font-size: 24rpx; line-height: 38rpx; }
.creating-progress { height: 10rpx; margin-top: 34rpx; border-radius: 999rpx; overflow: hidden; background: rgba(21,26,45,.06); }
.creating-progress view { width: 44%; height: 100%; border-radius: 999rpx; background: linear-gradient(90deg, #a9ffe7, #8b7cff, #ff9fc1); animation: loadingSlide 1.2s ease-in-out infinite; }
@keyframes creatingSpin { from { transform: rotate(0); } to { transform: rotate(360deg); } }
@keyframes creatingPulse { 0%, 100% { transform: scale(.98); } 50% { transform: scale(1.04); } }
@keyframes dotFloatA { 0%, 100% { transform: translate(0, 0); } 50% { transform: translate(-10rpx, 8rpx); } }
@keyframes dotFloatB { 0%, 100% { transform: translate(0, 0); } 50% { transform: translate(12rpx, -8rpx); } }
@keyframes loadingSlide { 0% { transform: translateX(-120%); } 100% { transform: translateX(230%); } }
</style>

137
package1/ieBrowser/space.vue

@ -0,0 +1,137 @@
<template>
<view class="space-page">
<view class="nav" :style="{ paddingTop: menuButtonInfo.top + 'px' }">
<view class="back" @tap="back"></view>
<view class="nav-title">{{ ownerName }}的空间</view>
</view>
<view class="owner-card">
<image class="owner-avatar-img" v-if="ownerAvatarUrl" :src="ownerAvatarUrl" mode="aspectFill"></image>
<view class="owner-avatar" v-else>{{ ownerAvatarText }}</view>
<view class="owner-name">{{ ownerName }}</view>
<view class="owner-mode">{{ ownerMode === 'e' ? 'e 人 · 轻轻热闹' : 'i 人 · 安静陪伴' }}</view>
</view>
<view class="moment-empty" v-if="!moments.length && !loading">TA 还没有发过动态</view>
<view class="moment-card" v-for="item in moments" :key="item.id">
<view class="moment-time">{{ momentTime(item.createTime) }}</view>
<view class="moment-text" v-if="item.content">{{ item.content }}</view>
<view class="moment-grid" v-if="item.imageList && item.imageList.length" :class="'g' + item.imageList.length">
<image v-for="(img, idx) in item.imageList" :key="idx" :src="img" mode="aspectFill" @tap="previewImages(item, idx)"></image>
</view>
<view class="moment-video" v-if="item.videoUrl" @tap="playVideo(item)">
<image class="moment-video-poster" v-if="item.videoPoster" :src="item.videoPoster" mode="aspectFill"></image>
<view class="moment-video-holder" v-else></view>
<view class="moment-play"></view>
</view>
</view>
<view class="load-line" v-if="loading">加载中...</view>
<view class="load-line" v-else-if="!hasMore && moments.length">没有更多动态了</view>
</view>
</template>
<script>
import { pageIeMoments } from '@/common/ieApi.js'
export default {
data() {
return {
menuButtonInfo: { top: 44 },
userId: null,
roomId: '',
ownerName: 'TA',
ownerAvatarText: 'TA',
ownerAvatarUrl: '',
ownerMode: 'i',
moments: [],
page: 1,
hasMore: true,
loading: false
}
},
onLoad(options = {}) {
if (uni.getMenuButtonBoundingClientRect) this.menuButtonInfo = uni.getMenuButtonBoundingClientRect()
this.userId = options.userId || null
this.roomId = options.roomId || ''
if (options.name) this.ownerName = decodeURIComponent(options.name)
if (options.avatar) this.ownerAvatarText = decodeURIComponent(options.avatar)
if (options.avatarUrl) this.ownerAvatarUrl = decodeURIComponent(options.avatarUrl)
if (options.mode) this.ownerMode = options.mode
this.loadMoments(true)
},
onReachBottom() {
this.loadMoments(false)
},
methods: {
back() {
uni.navigateBack({ fail: () => uni.redirectTo({ url: '/package1/ieBrowser/index' }) })
},
async loadMoments(reset) {
if (this.loading) return
if (!reset && !this.hasMore) return
if (!this.userId) return
this.loading = true
try {
const result = await pageIeMoments(this.userId, reset ? 1 : this.page, 10, this.roomId)
const records = (result && result.records) || []
this.moments = reset ? records : this.moments.concat(records)
this.hasMore = records.length >= 10
this.page = (reset ? 1 : this.page) + 1
const first = records.find(item => item.anonymousName)
if (first) {
this.ownerName = first.anonymousName || this.ownerName
this.ownerAvatarText = first.avatarText || this.ownerAvatarText
this.ownerAvatarUrl = first.avatarUrl || this.ownerAvatarUrl
this.ownerMode = first.currentMode || this.ownerMode
}
} finally {
this.loading = false
}
},
momentTime(timeStr) {
if (!timeStr) return ''
const time = new Date(String(timeStr).replace(/-/g, '/')).getTime()
if (!time) return ''
const diff = Date.now() - time
if (diff < 60000) return '刚刚'
if (diff < 3600000) return Math.floor(diff / 60000) + ' 分钟前'
if (diff < 86400000) return Math.floor(diff / 3600000) + ' 小时前'
if (diff < 172800000) return '昨天'
const d = new Date(time)
return (d.getMonth() + 1) + '月' + d.getDate() + '日'
},
previewImages(item, index) {
uni.previewImage({ urls: item.imageList || [], current: item.imageList[index] })
},
playVideo(item) {
if (!item.videoUrl) return
uni.navigateTo({ url: '/package1/ieBrowser/videoPreview?url=' + encodeURIComponent(item.videoUrl) })
}
}
}
</script>
<style lang="scss" scoped>
page { background: #f7f9ff; }
.space-page { min-height: 100vh; padding: 0 32rpx 80rpx; box-sizing: border-box; color: #161b2e; background: radial-gradient(circle at 50% 8%, rgba(169,255,231,.36), transparent 320rpx), linear-gradient(180deg, #fbfdff, #eef4ff 62%, #fff4e8); }
.nav { display: flex; align-items: center; padding-bottom: 16rpx; }
.back { width: 64rpx; height: 64rpx; line-height: 58rpx; text-align: center; border-radius: 50%; background: rgba(255,255,255,.7); font-size: 44rpx; color: rgba(22,27,46,.66); box-shadow: 0 10rpx 26rpx rgba(96,112,160,.12); }
.nav-title { margin-left: 20rpx; font-size: 32rpx; font-weight: 800; }
.owner-card { margin-top: 10rpx; padding: 34rpx; text-align: center; border-radius: 36rpx; background: rgba(255,255,255,.66); border: 1rpx solid rgba(255,255,255,.8); box-shadow: 0 20rpx 60rpx rgba(96,112,160,.12); }
.owner-avatar, .owner-avatar-img { width: 120rpx; height: 120rpx; margin: 0 auto; border-radius: 50%; }
.owner-avatar { line-height: 120rpx; text-align: center; background: linear-gradient(145deg, #effffb, #a9ffe7); font-size: 40rpx; font-weight: 800; color: #11162a; }
.owner-name { margin-top: 18rpx; font-size: 34rpx; font-weight: 800; }
.owner-mode { margin-top: 8rpx; color: #6c69d8; font-size: 23rpx; }
.moment-empty { margin-top: 60rpx; text-align: center; color: rgba(22,27,46,.4); font-size: 25rpx; }
.moment-card { margin-top: 24rpx; padding: 26rpx; border-radius: 28rpx; background: rgba(255,255,255,.85); border: 1rpx solid rgba(255,255,255,.9); box-shadow: 0 12rpx 32rpx rgba(96,112,160,.08); }
.moment-time { color: rgba(22,27,46,.42); font-size: 21rpx; }
.moment-text { margin-top: 14rpx; font-size: 27rpx; line-height: 42rpx; word-break: break-all; }
.moment-grid { display: flex; flex-wrap: wrap; margin-top: 16rpx; }
.moment-grid image { width: 196rpx; height: 196rpx; margin: 0 10rpx 10rpx 0; border-radius: 16rpx; background: rgba(22,27,46,.05); }
.moment-grid.g1 image { width: 400rpx; height: 400rpx; border-radius: 22rpx; }
.moment-grid.g2 image { width: 300rpx; height: 300rpx; }
.moment-video { position: relative; margin-top: 16rpx; width: 440rpx; height: 290rpx; border-radius: 22rpx; overflow: hidden; background: #1a2034; }
.moment-video-poster, .moment-video-holder { width: 100%; height: 100%; }
.moment-play { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); width: 84rpx; height: 84rpx; line-height: 84rpx; text-align: center; border-radius: 50%; color: #fff; background: rgba(0,0,0,.45); font-size: 32rpx; }
.load-line { margin-top: 26rpx; text-align: center; color: rgba(22,27,46,.36); font-size: 22rpx; }
</style>

479
package1/ieBrowser/universe.vue

@ -12,11 +12,10 @@
<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>{{ profile.dailyQuota || 3 }}</text><text>今日机会</text></view>
<view><text>{{ profile.currentMode || 'i' }}</text><text>当前状态</text></view>
<view><text>{{ profile.dailyQuota || 3 }}</text><text>今日匹配总次数</text></view>
<view class="space-stat" @tap="goMySpace"><text>我的空间</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>
@ -27,14 +26,61 @@
<image class="persona-image" v-for="(img, index) in profile.personaImages" :key="img" :src="img" mode="aspectFill" @tap="previewPersona(index)"></image>
</scroll-view>
</view>
<view class="panel">
<view class="panel-title">情绪轨道</view>
<view class="orbit-row" v-for="item in orbit" :key="item.day">
<view class="day">{{ item.day }}</view>
<view class="bar"><view :style="{ width: item.value + '%' }"></view></view>
<view class="mood">{{ item.mood }}</view>
<view class="profile-panel">
<view class="panel-head">
<view>
<view class="panel-title inline-title">我的 i/e 资料</view>
</view>
</view>
<view class="profile-edit-item" @tap="openProfileEditor('mode')">
<view class="profile-edit-icon">i/e</view>
<view>
<view class="profile-edit-title">当前身份</view>
<view class="profile-edit-desc">{{ modeText(profile.currentMode) }}</view>
</view>
<view class="edit-mini-btn">更改</view>
</view>
<view class="profile-edit-item" @tap="openProfileEditor('basic')">
<view class="profile-edit-icon"></view>
<view>
<view class="profile-edit-title">基础资料</view>
<view class="profile-edit-desc">{{ profile.anonymousName || '未设置昵称' }} · {{ genderText(profile.gender) }}</view>
</view>
<view class="edit-mini-btn">更改</view>
</view>
<view class="profile-edit-item" @tap="openProfileEditor('tags')">
<view class="profile-edit-icon">#</view>
<view>
<view class="profile-edit-title">兴趣标签</view>
<view class="profile-edit-desc">{{ profileTagsText }}</view>
</view>
<view class="edit-mini-btn">更改</view>
</view>
<view class="profile-edit-item" @tap="openProfileEditor('persona')">
<view class="profile-edit-icon"></view>
<view>
<view class="profile-edit-title">人格卡片</view>
<view class="profile-edit-desc">{{ profilePersonaText }}</view>
</view>
<view class="edit-mini-btn">更改</view>
</view>
<view class="profile-edit-item" @tap="openProfileEditor('target')">
<view class="profile-edit-icon"></view>
<view>
<view class="profile-edit-title">匹配偏好</view>
<view class="profile-edit-desc">{{ targetPreferenceText }}</view>
</view>
<view class="edit-mini-btn">更改</view>
</view>
<view class="profile-edit-item region">
<view class="profile-edit-icon"></view>
<view>
<view class="profile-edit-title">当前校区</view>
<view class="profile-edit-desc">{{ profile.regionName || '暂未选择校区' }}</view>
</view>
</view>
</view>
<view class="panel">
<view class="setting" @tap="goSettings">
<view>
@ -45,12 +91,98 @@
</view>
</view>
<ie-bottom-tab active="universe" :unread-count="unreadCount"></ie-bottom-tab>
<view class="edit-mask" v-if="editSection" @tap="closeProfileEditor">
<view class="edit-sheet" @tap.stop>
<view class="edit-head">
<view>
<view class="edit-title">{{ editTitle }}</view>
<view class="edit-sub">只修改这一项其它资料会保留</view>
</view>
<view class="edit-close" @tap="closeProfileEditor">×</view>
</view>
<view v-if="editSection === 'mode'">
<view class="mode-card i" :class="{ active: editForm.currentMode === 'i' }" @tap="editForm.currentMode = 'i'">
<view class="mode-mark">i</view>
<view>
<view class="mode-title">安静陪伴</view>
<view class="mode-desc">慢回复允许沉默低压力靠近</view>
</view>
</view>
<view class="mode-card e" :class="{ active: editForm.currentMode === 'e' }" @tap="editForm.currentMode = 'e'">
<view class="mode-mark">e</view>
<view>
<view class="mode-title">轻轻热闹</view>
<view class="mode-desc">有人开场聊点废话把情绪拉亮</view>
</view>
</view>
</view>
<view v-if="editSection === 'basic'">
<view class="avatar-edit" @tap="chooseProfileAvatar">
<image class="avatar-edit-img" v-if="editForm.avatarUrl" :src="editForm.avatarUrl" mode="aspectFill"></image>
<view class="avatar-edit-text" v-else>{{ editAvatarPreview }}</view>
<view class="avatar-edit-tip">点一下换头像</view>
</view>
<view class="random-name" @tap="randomEditName">随机一个昵称</view>
<input class="edit-input" v-model="editForm.anonymousName" maxlength="16" placeholder="昵称,例如:南门慢跑员" />
<view class="edit-label">性别</view>
<view class="chip-row">
<view class="edit-chip" v-for="item in genderOptions" :key="item.key" :class="{ active: editForm.gender === item.key }" @tap="editForm.gender = item.key">
{{ item.label }}
</view>
</view>
<textarea class="edit-textarea" v-model="editForm.intro" maxlength="80" placeholder="一句轻介绍:我通常慢热,但会认真听。" />
</view>
<view v-if="editSection === 'tags'">
<view class="tag-edit-grid">
<view class="edit-tag" v-for="item in tagOptions" :key="item" :class="{ active: editForm.interestTags.includes(item) }" @tap="toggleEditTag(item)">
{{ item }}
</view>
</view>
<view class="edit-help">最多选择 6 个标签</view>
</view>
<view v-if="editSection === 'persona'">
<view class="persona-edit-grid">
<view class="persona-edit-item" v-for="(img, index) in editForm.personaImages" :key="img">
<image :src="img" mode="aspectFill" @tap="previewEditPersona(index)"></image>
<view class="persona-edit-delete" @tap.stop="removeEditPersonaImage(index)">×</view>
</view>
<view class="persona-edit-add" v-if="editForm.personaImages.length < 5" @tap="chooseProfilePersonaImages">
<view class="plus"></view>
<view>添加图片</view>
</view>
</view>
<view class="edit-help">最多 5 会展示在你的个人空间和聊天资料里</view>
</view>
<view v-if="editSection === 'target'">
<view class="edit-label">想遇见哪类人</view>
<view class="target-card" v-for="item in targetOptions" :key="item.key" :class="{ active: editForm.targetModePreference === item.key }" @tap="editForm.targetModePreference = item.key">
<view class="target-title">{{ item.title }}</view>
<view class="target-desc">{{ item.desc }}</view>
</view>
<view class="edit-label">想匹配的性别</view>
<view class="chip-row">
<view class="edit-chip" v-for="item in targetGenderOptions" :key="item.key" :class="{ active: editForm.targetGenderPreference === item.key }" @tap="editForm.targetGenderPreference = item.key">
{{ item.label }}
</view>
</view>
</view>
<view class="edit-save" :class="{ disabled: savingProfile }" @tap="saveProfileSection">{{ savingProfile ? '保存中...' : '保存修改' }}</view>
</view>
</view>
</view>
</template>
<script>
import { getIeProfile, getIeUnreadCount } from '@/common/ieApi.js'
import { getIeProfile, saveIeProfile, getIeUnreadCount } from '@/common/ieApi.js'
import IeBottomTab from '@/components/ie-bottom-tab/ie-bottom-tab.vue'
import tui from '@/common/httpRequest.js'
export default {
components: { IeBottomTab },
@ -60,11 +192,44 @@
unreadCount: 0,
unreadTimer: null,
profile: {},
orbit: [
{ day: '今天', value: 72, mood: '轻了一点' },
{ day: '昨天', value: 48, mood: '安静' },
{ day: '周六', value: 86, mood: '被听见' }
]
editSection: '',
editForm: this.emptyProfileForm(),
savingProfile: false,
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: {
profileTagsText() {
const tags = this.profile.interestTags || []
return tags.length ? tags.slice(0, 4).join(' · ') : '还没有选择标签'
},
profilePersonaText() {
const count = (this.profile.personaImages || []).length
return count ? `${count} 张人格卡片` : '还没有上传人格卡片'
},
targetPreferenceText() {
return `${this.targetModeText(this.profile.targetModePreference)} · ${this.targetGenderText(this.profile.targetGenderPreference)}`
},
editTitle() {
const map = {
mode: '更改当前身份',
basic: '编辑基础资料',
tags: '编辑兴趣标签',
persona: '编辑人格卡片',
target: '编辑匹配偏好'
}
return map[this.editSection] || '编辑资料'
},
editAvatarPreview() {
return (this.editForm.anonymousName || this.editForm.avatarText || '我').slice(0, 1)
}
},
onLoad(options = {}) {
@ -102,12 +267,223 @@
const profile = await getIeProfile()
if (profile) this.profile = profile
},
backHome() { uni.redirectTo({ url: '/pages/index/index' }) },
editProfile() { uni.navigateTo({ url: '/package1/ieBrowser/profileSetup?edit=1' }) },
backHome() { uni.switchTab({ url: '/pages/index/index' }) },
emptyProfileForm() {
return {
currentMode: 'i',
targetModePreference: 'any',
targetGenderPreference: 'any',
anonymousName: '',
avatarText: '我',
avatarUrl: '',
gender: 'unknown',
intro: '',
interestTags: [],
personaImages: [],
regionId: '',
regionName: ''
}
},
cloneProfile(profile = {}) {
return {
...this.emptyProfileForm(),
currentMode: profile.currentMode || 'i',
targetModePreference: profile.targetModePreference || 'any',
targetGenderPreference: profile.targetGenderPreference || 'any',
anonymousName: profile.anonymousName || '',
avatarText: profile.avatarText || '我',
avatarUrl: profile.avatarUrl || '',
gender: profile.gender || 'unknown',
intro: profile.intro || '',
interestTags: (profile.interestTags || []).slice(),
personaImages: (profile.personaImages || []).slice(),
regionId: profile.regionId || '',
regionName: profile.regionName || ''
}
},
openProfileEditor(section) {
this.editSection = section
this.editForm = this.cloneProfile(this.profile)
},
closeProfileEditor() {
if (this.savingProfile) return
this.editSection = ''
this.editForm = this.emptyProfileForm()
},
modeShortText(mode) {
return mode === 'e' ? 'e' : 'i'
},
modeText(mode) {
return mode === 'e' ? 'e 人 · 轻轻热闹' : 'i 人 · 安静陪伴'
},
genderText(gender) {
if (gender === 'male') return '男生'
if (gender === 'female') return '女生'
return '不想说'
},
targetModeText(mode) {
if (mode === 'i') return '想遇见 i 人'
if (mode === 'e') return '想遇见 e 人'
return '都可以'
},
targetGenderText(gender) {
if (gender === 'male') return '男生'
if (gender === 'female') return '女生'
return '不限性别'
},
toggleEditTag(tag) {
const index = this.editForm.interestTags.indexOf(tag)
if (index > -1) {
this.editForm.interestTags.splice(index, 1)
return
}
if (this.editForm.interestTags.length >= 6) {
uni.showToast({ title: '最多选择 6 个标签', icon: 'none' })
return
}
this.editForm.interestTags.push(tag)
},
randomEditName() {
this.editForm.anonymousName = this.nameSeeds[Math.floor(Math.random() * this.nameSeeds.length)]
},
mergedProfilePayload() {
const base = this.cloneProfile(this.profile)
const form = this.cloneProfile(this.editForm)
let merged = { ...base }
if (this.editSection === 'mode') {
merged.currentMode = form.currentMode
} else if (this.editSection === 'basic') {
merged.anonymousName = form.anonymousName
merged.avatarText = this.editAvatarPreview
merged.avatarUrl = form.avatarUrl
merged.gender = form.gender
merged.intro = form.intro
} else if (this.editSection === 'tags') {
merged.interestTags = form.interestTags
} else if (this.editSection === 'persona') {
merged.personaImages = form.personaImages
} else if (this.editSection === 'target') {
merged.targetModePreference = form.targetModePreference
merged.targetGenderPreference = form.targetGenderPreference
}
return merged
},
validateProfileSection() {
if (this.editSection === 'basic' && !this.editForm.anonymousName.trim()) {
uni.showToast({ title: '先起一个昵称吧', icon: 'none' })
return false
}
if (this.editSection === 'tags' && !this.editForm.interestTags.length) {
uni.showToast({ title: '至少选一个标签', icon: 'none' })
return false
}
return true
},
async saveProfileSection() {
if (this.savingProfile) return
if (!this.validateProfileSection()) return
this.savingProfile = true
uni.showLoading({ title: '保存中...', mask: true })
try {
const payload = this.mergedProfilePayload()
const saved = await saveIeProfile(payload)
if (!saved) return
this.profile = saved
this.editSection = ''
this.editForm = this.emptyProfileForm()
uni.showToast({ title: '已保存', icon: 'none' })
} finally {
this.savingProfile = false
uni.hideLoading()
}
},
chooseProfileAvatar() {
uni.chooseMedia({
count: 1,
mediaType: ['image'],
sourceType: ['camera', 'album'],
camera: 'back',
success: async (res) => {
const file = res.tempFiles && res.tempFiles[0]
if (!file || !file.tempFilePath) return
uni.showLoading({ title: '上传中...', mask: true })
try {
this.editForm.avatarUrl = await this.silentUpload(file.tempFilePath)
} catch (e) {
uni.showToast({ title: '头像上传失败,请重试', icon: 'none' })
} finally {
uni.hideLoading()
}
}
})
},
chooseProfilePersonaImages() {
const remain = 5 - this.editForm.personaImages.length
if (remain <= 0) {
uni.showToast({ title: '最多上传 5 张', icon: 'none' })
return
}
uni.chooseMedia({
count: remain,
mediaType: ['image'],
sourceType: ['camera', 'album'],
camera: 'back',
success: async (res) => {
const files = res.tempFiles || []
if (!files.length) return
uni.showLoading({ title: '上传中...', mask: true })
try {
for (const file of files) {
if (this.editForm.personaImages.length >= 5) break
if (!file || !file.tempFilePath) continue
const url = await this.silentUpload(file.tempFilePath)
if (url) this.editForm.personaImages.push(url)
}
} catch (e) {
uni.showToast({ title: '图片上传失败,请重试', icon: 'none' })
} finally {
uni.hideLoading()
}
}
})
},
removeEditPersonaImage(index) {
this.editForm.personaImages.splice(index, 1)
},
previewEditPersona(index) {
uni.previewImage({ urls: this.editForm.personaImages || [], current: this.editForm.personaImages[index] })
},
previewPersona(index) {
uni.previewImage({ urls: this.profile.personaImages || [], current: this.profile.personaImages[index] })
},
goSettings() { uni.navigateTo({ url: '/package1/ieBrowser/settings' }) }
goMySpace() {
uni.navigateTo({ url: '/package1/ieBrowser/mySpace' })
},
goSettings() { uni.navigateTo({ url: '/package1/ieBrowser/settings' }) },
// tui.uploadFile loading Promise
silentUpload(filePath) {
return new Promise((resolve, reject) => {
uni.uploadFile({
url: tui.interfaceUrl() + '/upload/file',
filePath,
name: 'file',
timeout: 300000,
header: { accessToken: uni.getStorageSync('hiver_token') },
success: (res) => {
try {
const d = JSON.parse(String(res.data || '{}').replace(/\ufeff/g, ''))
const fileObj = d.result !== undefined ? d.result : d.data
const url = typeof fileObj === 'string' ? fileObj : (fileObj && (fileObj.url || fileObj.fileUrl || fileObj.path || fileObj.fullPath))
if (d.code % 100 === 0 && url) resolve(url)
else reject(new Error(d.message || '上传失败'))
} catch (e) {
reject(e)
}
},
fail: () => reject(new Error('上传失败'))
})
})
}
}
}
</script>
@ -115,22 +491,39 @@
<style lang="scss" scoped>
page { background: #f7f9ff; }
.universe-page { min-height: 100vh; padding: 0 32rpx 230rpx; box-sizing: border-box; color: #161b2e; background: radial-gradient(circle at 50% 12%, rgba(169,255,231,.42), transparent 330rpx), radial-gradient(circle at 86% 28%, rgba(255,184,209,.24), transparent 320rpx), linear-gradient(180deg, #fbfdff, #eef4ff 62%, #fff4e8); }
.float-bar { position: relative; z-index: 5; display: flex; align-items: center; justify-content: space-between; margin-top: 14rpx; }
.float-bar { position: relative; z-index: 5; display: flex; align-items: center; justify-content: space-between;}
.home-back { display: flex; align-items: center; height: 58rpx; padding: 0 22rpx 0 12rpx; border: 1rpx solid rgba(255,255,255,.88); border-radius: 999rpx; color: rgba(22,27,46,.66); background: rgba(255,255,255,.66); backdrop-filter: blur(20rpx); box-shadow: 0 14rpx 36rpx rgba(96,112,160,.12), inset 0 1rpx 0 rgba(255,255,255,.95); font-size: 23rpx; font-weight: 800; }
.home-back:active { transform: scale(.95); background: rgba(169,255,231,.7); }
.home-back-icon { margin-right: 6rpx; font-size: 40rpx; line-height: 50rpx; font-weight: 400; }
.home-back-icon { margin-right: 6rpx;padding-bottom: 6rpx; font-size: 40rpx; line-height: 50rpx; font-weight: 400; }
.profile { padding-top: 18rpx; text-align: center; }
.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 { display: flex; gap: 18rpx; margin-top: 36rpx; padding: 18rpx; 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:first-child { font-size: 34rpx; font-weight: 800; color: #151a2d; }
.stats text:last-child { margin-top: 8rpx; color: rgba(22,27,46,.42); font-size: 21rpx; }
.stats view { padding: 16rpx 8rpx; border-radius: 26rpx; }
.space-stat { background: rgba(238,244,255,.62); border: 1rpx solid rgba(255,255,255,.78); }
.space-stat:active { transform: scale(.97); }
.space-stat text:first-child { font-size: 34rpx; }
.space-stat text:last-child { color: rgba(108,105,216,.72); font-weight: 800; }
.profile-panel { position: relative; margin-top: 26rpx; padding: 32rpx 28rpx 28rpx; border-radius: 40rpx; background: rgba(255,255,255,.64); border: 1rpx solid rgba(255,255,255,.86); backdrop-filter: blur(24rpx); box-shadow: 0 24rpx 76rpx rgba(96,112,160,.12); overflow: hidden; }
.profile-panel::before { content: ''; position: absolute; right: -120rpx; top: -120rpx; width: 280rpx; height: 280rpx; border-radius: 50%; background: radial-gradient(circle, rgba(169,255,231,.26), transparent 70%); pointer-events: none; }
.panel-head { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 18rpx; }
.inline-title { margin-bottom: 8rpx; }
.panel-sub { color: rgba(22,27,46,.44); font-size: 22rpx; line-height: 34rpx; }
.profile-edit-item { position: relative; z-index: 1; display: flex; align-items: center; margin-top: 16rpx; padding: 22rpx 24rpx; border-radius: 28rpx; background: rgba(255,255,255,.78); border: 1rpx solid rgba(255,255,255,.9); box-shadow: 0 12rpx 32rpx rgba(96,112,160,.07); }
.profile-edit-item:active { transform: scale(.98); background: rgba(223,254,244,.7); }
.profile-edit-item.region:active { transform: none; background: rgba(255,255,255,.72); }
.profile-edit-item > view:nth-child(2) { flex: 1; min-width: 0; }
.profile-edit-icon { flex-shrink: 0; width: 58rpx; height: 58rpx; margin-right: 18rpx; border-radius: 20rpx; text-align: center; line-height: 58rpx; color: #5a55c8; background: linear-gradient(145deg, rgba(238,244,255,.96), rgba(223,254,244,.92)); font-size: 22rpx; font-weight: 900; box-shadow: inset 0 1rpx 0 rgba(255,255,255,.95); }
.profile-edit-title { font-size: 26rpx; font-weight: 800; }
.profile-edit-desc { margin-top: 8rpx; color: rgba(22,27,46,.5); font-size: 22rpx; line-height: 34rpx; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.edit-mini-btn { flex-shrink: 0; margin-left: 18rpx; padding: 8rpx 18rpx; border-radius: 999rpx; color: #6c69d8; background: rgba(139,124,255,.1); font-size: 21rpx; font-weight: 800; }
.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; }
.persona-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); }
@ -138,11 +531,41 @@
.persona-image { display: inline-block; width: 214rpx; height: 260rpx; margin-right: 18rpx; border-radius: 28rpx; background: rgba(22,27,46,.06); box-shadow: 0 16rpx 42rpx rgba(96,112,160,.12); }
.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; }
.day { width: 86rpx; color: rgba(22,27,46,.52); font-size: 22rpx; }
.bar { flex: 1; height: 12rpx; border-radius: 999rpx; background: rgba(22,27,46,.06); overflow: hidden; }
.bar view { height: 100%; border-radius: 999rpx; background: #a9ffe7; }
.mood { width: 120rpx; text-align: right; color: #6c69d8; font-size: 22rpx; }
.edit-mask { position: fixed; inset: 0; z-index: 130; display: flex; align-items: flex-end; padding: 0 28rpx 42rpx; box-sizing: border-box; background: rgba(13,17,32,.36); backdrop-filter: blur(12rpx); }
.edit-sheet { width: 100%; max-height: 82vh; overflow-y: auto; padding: 34rpx 30rpx 30rpx; border-radius: 42rpx; box-sizing: border-box; background: linear-gradient(180deg, rgba(255,255,255,.98), rgba(247,249,255,.96)); border: 1rpx solid rgba(255,255,255,.9); box-shadow: 0 34rpx 90rpx rgba(42,50,86,.22); }
.edit-head { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 24rpx; }
.edit-title { font-size: 34rpx; font-weight: 900; color: #151a2d; }
.edit-sub { margin-top: 8rpx; color: rgba(21,26,45,.48); font-size: 23rpx; }
.edit-close { width: 58rpx; height: 58rpx; border-radius: 50%; text-align: center; line-height: 54rpx; color: rgba(21,26,45,.5); background: rgba(238,244,255,.8); font-size: 36rpx; font-weight: 700; }
.mode-card, .target-card { display: flex; align-items: center; margin-top: 18rpx; padding: 24rpx; border-radius: 28rpx; background: rgba(238,244,255,.78); border: 2rpx solid transparent; }
.mode-card.active, .target-card.active { border-color: #a9ffe7; background: rgba(223,254,244,.78); }
.mode-mark { flex-shrink: 0; width: 72rpx; height: 72rpx; margin-right: 20rpx; border-radius: 24rpx; text-align: center; line-height: 72rpx; font-size: 42rpx; font-weight: 900; }
.i .mode-mark { color: #fff; background: #7771d8; }
.e .mode-mark { color: #11162a; background: #a9ffe7; }
.mode-title, .target-title { font-size: 28rpx; font-weight: 800; }
.mode-desc, .target-desc { margin-top: 8rpx; color: rgba(21,26,45,.54); font-size: 23rpx; line-height: 36rpx; }
.avatar-edit { width: 184rpx; margin: 0 auto 26rpx; text-align: center; }
.avatar-edit-img, .avatar-edit-text { width: 154rpx; height: 154rpx; margin: 0 auto; border-radius: 50%; }
.avatar-edit-img { display: block; }
.avatar-edit-text { text-align: center; line-height: 154rpx; color: #11162a; background: linear-gradient(145deg, #effffb, #a9ffe7); font-size: 58rpx; font-weight: 800; }
.avatar-edit-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; }
.edit-input, .edit-textarea { width: 100%; box-sizing: border-box; border-radius: 26rpx; background: rgba(238,244,255,.82); color: #151a2d; font-size: 26rpx; }
.edit-input { height: 82rpx; padding: 0 24rpx; }
.edit-textarea { height: 170rpx; margin-top: 20rpx; padding: 22rpx 24rpx; line-height: 40rpx; }
.edit-label { margin: 24rpx 0 14rpx; color: rgba(21,26,45,.52); font-size: 23rpx; font-weight: 800; }
.chip-row, .tag-edit-grid, .persona-edit-grid { display: flex; flex-wrap: wrap; }
.edit-chip, .edit-tag { 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; }
.edit-chip.active, .edit-tag.active { color: #11162a; background: #a9ffe7; font-weight: 800; }
.edit-help { margin-top: 10rpx; color: rgba(21,26,45,.4); font-size: 22rpx; line-height: 34rpx; }
.persona-edit-grid { gap: 18rpx; }
.persona-edit-item, .persona-edit-add { position: relative; width: 184rpx; height: 224rpx; border-radius: 30rpx; overflow: hidden; background: rgba(238,244,255,.82); }
.persona-edit-item image { width: 100%; height: 100%; display: block; }
.persona-edit-delete { position: absolute; right: 12rpx; top: 12rpx; width: 42rpx; height: 42rpx; border-radius: 50%; text-align: center; line-height: 38rpx; color: #fff; background: rgba(21,26,45,.56); font-size: 34rpx; }
.persona-edit-add { display: flex; flex-direction: column; align-items: center; justify-content: center; color: rgba(21,26,45,.46); border: 2rpx dashed rgba(108,105,216,.22); box-sizing: border-box; font-size: 23rpx; }
.persona-edit-add .plus { margin-bottom: 10rpx; color: #6c69d8; font-size: 44rpx; font-weight: 800; }
.edit-save { height: 88rpx; line-height: 88rpx; margin-top: 28rpx; border-radius: 999rpx; text-align: center; color: #11162a; background: linear-gradient(135deg, #effffb, #a9ffe7); box-shadow: 0 16rpx 34rpx rgba(169,255,231,.28); font-size: 28rpx; font-weight: 900; }
.edit-save.disabled { opacity: .56; }
.setting { display: flex; align-items: center; }
.setting-title { font-size: 28rpx; font-weight: 800; }
.setting-desc { margin-top: 8rpx; color: rgba(22,27,46,.48); font-size: 22rpx; }

Loading…
Cancel
Save