You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

414 lines
19 KiB

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