chore: 重写初始提交(清空历史,整理后全量提交)

This commit is contained in:
ywxapp
2026-08-16 16:54:14 +08:00
commit 6c1a106bc1
1808 changed files with 238144 additions and 0 deletions
@@ -0,0 +1,473 @@
<template>
<view class="card-panel">
<!-- 用户信息 -->
<view class="card-panel-header">
<view class="user-info" @click="onUserClick">
<image class="avatar" :src="moment.userAvatar" mode="aspectFill"></image>
<view class="user-details">
<view class="user-name-row">
<text class="user-name">{{ moment.userName }}</text>
<text v-if="moment.userVip" class="vip-badge">V</text>
<text v-if="moment.hot" class="hot-badge">🔥</text>
</view>
<view class="meta-info">
<text class="location">{{ moment.location }}</text>
<text class="time">{{ formatTime(moment.time) }}</text>
</view>
</view>
</view>
<view class="more-btn" @click="onMoreClick">
<uni-icons type="more" size="20" color="#999"></uni-icons>
</view>
</view>
<!-- 动态内容 -->
<view class="card-panel-body" @click="onContentClick">
<text class="content-text">{{ moment.content }}</text>
<!-- 图片内容 -->
<view v-if="moment.type === 'image' && moment.images.length > 0" class="image-content">
<view v-if="moment.images.length == 1" class="single-image" @click="previewImage(0)">
<image class="image-item" :src="moment.images[0]" mode="aspectFill"></image>
</view>
<view v-else-if="moment.images.length < 3" class="multi-images-row">
<view v-for="(img, index) in moment.images" :key="index" class="image-item-small"
@click="previewImage(index)">
<image class="image" :src="img" mode="aspectFill"></image>
</view>
</view>
<view v-else class="multi-images-grid">
<view v-for="(img, index) in moment.images.slice(0, 4)" :key="index" class="grid-image"
:class="{ 'last-image': index == 3 && moment.images.length > 4 }" @click="previewImage(index)">
<image class="image" :src="img" mode="aspectFill"></image>
<view v-if="index == 3 && moment.images.length > 4" class="image-count">
+{{ moment.images.length - 4 }}
</view>
</view>
</view>
</view>
<!-- 视频内容 -->
<view v-else-if="moment.type === 'video'" class="video-content" @click="playVideo">
<image v-if="moment.images.length > 0" class="video-cover" :src="moment.images[0] " mode="aspectFill">
</image>
<view class="video-play-btn">
<uni-icons type="play" size="40" color="#fff"></uni-icons>
</view>
<view class="video-duration">03:25</view>
</view>
<!-- 标签 -->
<view v-if="moment.tags != null" class="moment-tags">
<view v-for="(tag, index) in moment.tags" :key="index" class="tag-item">
<text class="tag-text">#{{ tag }}</text>
</view>
</view>
</view>
<!-- 互动操作栏 -->
<view class="card-panel-footer">
<view class="action-item" @click="onLikeClick">
<uni-icons :type="moment.isLiked ? 'heart-filled' : 'heart'" size="20"
:color="moment.isLiked ? '#FF2D55' : '#666'"></uni-icons>
<text class="action-text" :style="{ color: moment.isLiked ? '#FF2D55' : '#666' }">
{{ formatNumber(moment.likes) }}
</text>
</view>
<view class="action-item" @click="onCommentClick">
<uni-icons type="chat" size="20" color="#666"></uni-icons>
<text class="action-text">{{ formatNumber(moment.comments) }}</text>
</view>
<view class="action-item" @click="onShareClick">
<uni-icons type="redo" size="20" color="#666"></uni-icons>
<text class="action-text">{{ formatNumber(moment.shares) }}</text>
</view>
<view class="action-item distance" v-if="moment.distance !=0 && moment.distance < 50">
<uni-icons type="location" size="16" color="#999"></uni-icons>
<text class="distance-text">{{ formatDistance(moment.distance) }}</text>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { IMoment } from '@/types/dynamicType.uts'
const props = defineProps<{
moment : IMoment
}>()
const emit = defineEmits<{
like : [id: number]
comment : [id: number]
share : [id: number]
more : [id: number]
}>()
// 格式化时间
const formatTime = (timestamp : number) : string => {
const now = Date.now()
const diff = now - timestamp
if (diff < 60000) { // 1分钟内
return '刚刚'
} else if (diff < 3600000) { // 1小时内
return Math.floor(diff / 60000) + '分钟前'
} else if (diff < 86400000) { // 1天内
return Math.floor(diff / 3600000) + '小时前'
} else if (diff < 604800000) { // 1周内
return Math.floor(diff / 86400000) + '天前'
} else {
const date = new Date(timestamp)
return `${date.getMonth() + 1}月${date.getDate()}日`
}
}
// 格式化数字
const formatNumber = (num : number) : string => {
if (num >= 10000) {
return (num / 10000).toFixed(1) + 'w'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'k'
}
return num.toString()
}
// 格式化距离
const formatDistance = (distance : number) : string => {
if (distance < 1) {
return '<1km'
} else if (distance < 10) {
return distance.toFixed(1) + 'km'
} else {
return Math.floor(distance) + 'km'
}
}
// 点击用户
const onUserClick = () => {
console.log('点击用户:', props.moment.userName)
uni.navigateTo({
url: `/pages/user-detail/user-detail?id=${props.moment.userId}`
})
}
// 点击更多
const onMoreClick = () => {
emit('more', props.moment.id)
}
// 点击内容
const onContentClick = () => {
console.log('点击动态内容:', props.moment.id)
let page = `/pages/moment/details?id=${props.moment.id}`;
console.log(page)
uni.navigateTo({
url: page
})
}
// 预览图片
const previewImage = (index : number) => {
console.log('预览图片:', index)
uni.previewImage({
current: index,
urls: props.moment.images
})
}
// 播放视频
const playVideo = () => {
console.log('播放视频')
uni.navigateTo({
url: `/pages/video-player/video-player?url=${encodeURIComponent(props.moment.videoUrl)}`
})
}
// 点赞
const onLikeClick = () => {
emit('like', props.moment.id)
}
// 评论
const onCommentClick = () => {
emit('comment', props.moment.id)
}
// 分享
const onShareClick = () => {
emit('share', props.moment.id)
}
</script>
<style>
/* 用户信息 */
.user-info {
display: flex;
flex-flow: row nowrap;
align-items: center;
flex: 1;
}
.avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
margin-right: 20rpx;
background-color: #f0f0f0;
}
.user-details {
flex: 1;
}
.user-name-row {
display: flex;
flex-flow: row nowrap;
align-items: center;
margin-bottom: 8rpx;
}
.user-name {
font-size: 30rpx;
font-weight: bold;
color: #333;
margin-right: 10rpx;
max-width: 200rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vip-badge {
background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%);
color: #fff;
font-size: 20rpx;
padding: 3rpx 10rpx;
border-radius: 20rpx;
margin-right: 10rpx;
}
.hot-badge {
background-color: #f30f025c;
color: #fff;
font-size: 20rpx;
padding: 3rpx 10rpx;
border-radius: 20rpx;
}
.meta-info {
display: flex;
flex-flow: row nowrap;
align-items: center;
flex-wrap: wrap;
}
.location,
.time {
font-size: 24rpx;
color: #999;
margin-right: 20rpx;
}
.more-btn {
width: 60rpx;
height: 60rpx;
display: flex;
justify-content: center;
align-items: center;
border-radius: 50%;
background-color: #f8f8f8;
}
/* 动态内容 */
.moment-content {
margin-bottom: 25rpx;
}
.content-text {
font-size: 30rpx;
color: #333;
line-height: 1.5;
margin-bottom: 20rpx;
display: flex;
overflow: hidden;
}
/* 图片内容 */
.image-content {
margin: 20rpx 0;
}
.single-image {
width: 100%;
height: 400rpx;
border-radius: 20rpx;
overflow: hidden;
}
.image-item {
width: 100%;
height: 100%;
background-color: #f0f0f0;
}
.multi-images-row {
display: flex;
flex-flow: row nowrap;
margin-bottom: 20rpx;
}
.image-item-small {
flex: 1;
height: 375rpx;
border-radius: 20rpx;
overflow: hidden;
padding: 5rpx;
}
.image-item-small .image {
width: 100%;
height: 100%;
background-color: #f0f0f0;
}
.multi-images-grid {
display: flex;
flex-flow: row wrap;
overflow: hidden;
}
.grid-image {
margin: 5rpx;
position: relative;
flex-basis: 210rpx;
height: 230rpx;
box-sizing: border-box;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
}
.grid-image .image {
width: 100%;
height: 100%;
background-color: #f0f0f0;
}
.grid-image.last-image::after {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
}
.image-count {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 40rpx;
color: #fff;
font-weight: bold;
z-index: 1;
}
/* 视频内容 */
.video-content {
position: relative;
height: 400rpx;
border-radius: 20rpx;
overflow: hidden;
margin: 20rpx 0;
}
.video-cover {
width: 100%;
height: 100%;
background-color: #000;
}
.video-play-btn {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100rpx;
height: 100rpx;
background-color: rgba(0, 0, 0, 0.6);
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
}
.video-duration {
position: absolute;
bottom: 20rpx;
right: 20rpx;
padding: 8rpx 20rpx;
background-color: rgba(0, 0, 0, 0.7);
border-radius: 20rpx;
}
/* 标签 */
.moment-tags {
display: flex;
flex-flow: row nowrap;
margin-top: 20rpx;
}
.tag-item {
padding: 8rpx 20rpx;
background-color: #f0f0f0;
border-radius: 20rpx;
}
.tag-text {
font-size: 24rpx;
color: #007AFF;
}
/* 互动操作栏 */
.action-bar {
display: flex;
flex-flow: row nowrap;
align-items: center;
padding-top: 20rpx;
border-top: 1rpx solid #f0f0f0;
}
.action-item {
display: flex;
align-items: center;
margin-right: 40rpx;
padding: 10rpx 0;
}
.action-item .uni-icons {
margin-right: 10rpx;
}
.action-text {
font-size: 24rpx;
color: #666;
}
.action-item.distance {
margin-left: auto;
margin-right: 0;
}
.distance-text {
font-size: 24rpx;
color: #999;
margin-left: 8rpx;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,451 @@
<template>
<view class="page">
<!-- 顶部导航栏 -->
<view class="page-nav">
<view class="page-nav-left">
<scroll-view class="tabsbox" :show-scrollbar="false" direction="horizontal">
<view v-for="(tab, index) in tabs" :key="index" class="tabsbox-item"
:class="{ 'tabsbox-active': activeTab === index }" @click="switchTab(index)">
<text class="tabsbox-text" :class="{'tabsbox-active-txt': activeTab == index }">{{ tab.name }}</text>
<!-- <text v-if="tab.badge" class="badge">{{ tab.badge }}</text> -->
</view>
</scroll-view>
</view>
<view class="page-nav-right">
<view class="icon-btn" @click="onSearchClick">
<uni-icons type="search" size="24" color="#333"></uni-icons>
</view>
<view class="icon-btn" @click="onPublishClick">
<uni-icons type="plusempty" size="24" color="#333"></uni-icons>
</view>
</view>
</view>
<view class="page-body" style="">
<scroll-view class="moment-list" :show-scrollbar="false" direction="vertical" :refresher-enabled="true" :refresher-triggered="refreshing"
@refresherrefresh="onRefresh" @scrolltolower="onLoadMore" style="padding-bottom: 96rpx;">
<!-- 置顶动态 -->
<view v-if="pinnedMoments.length > 0" class="pinned-section">
<text class="section-title">置顶动态</text>
<view v-for="moment in pinnedMoments" :key="'pinned-' + moment.id" class="moment-item pinned">
<moment-card :moment="moment" @like="onLike" @comment="onComment" @share="onShare"
@more="onMore" />
</view>
</view>
<!-- 推荐动态 -->
<view class="recommend-section" v-if="activeTab == 0">
<text class="section-title">推荐动态</text>
<view v-for="moment in recommendMoments" :key="'recommend-' + moment.id" class="moment-item">
<moment-card :moment="moment" @like="onLike" @comment="onComment" @share="onShare"
@more="onMore" />
</view>
</view>
<!-- 关注动态 -->
<view class="follow-section" v-if="activeTab == 1">
<view v-if="followMoments.length > 0">
<view v-for="moment in followMoments" :key="'follow-' + moment.id" class="moment-item">
<moment-card :moment="moment" @like="onLike" @comment="onComment" @share="onShare"
@more="onMore" />
</view>
</view>
<view v-else class="empty-state">
<uni-icons type="person" size="60" color="#ccc"></uni-icons>
<text class="empty-text">还没有关注的好友动态</text>
<button class="empty-btn" @click="findFriends">去发现好友</button>
</view>
</view>
<!-- 附近动态 -->
<view class="nearby-section" v-if="activeTab == 2">
<view class="section-header">
<text class="section-title">附近动态</text>
<text class="section-subtitle">{{ nearbyCount }}条动态</text>
</view>
<view v-for="moment in nearbyMoments" :key="'nearby-' + moment.id" class="moment-item">
<moment-card :moment="moment" @like="onLike" @comment="onComment" @share="onShare"
@more="onMore" />
</view>
</view>
<!-- 热门动态 -->
<view class="hot-section" v-if="activeTab == 3">
<view class="section-header">
<text class="section-title">热门动态</text>
<view class="hot-tags">
<view v-for="(tag, index) in hotTags" :key="index" class="hot-tag"
:class="{ active: activeHotTag == index }" @click="switchHotTag(index)">
<text class="tag-text">{{ tag }}</text>
</view>
</view>
</view>
<view v-for="moment in hotMoments" :key="'hot-' + moment.id" class="moment-item">
<moment-card :moment="moment" @like="onLike" @comment="onComment" @share="onShare"
@more="onMore" />
</view>
</view>
<!-- 加载状态 -->
<!-- <view class="load-status">
<uni-load-more :status="loadStatus" />
</view> -->
</scroll-view>
<!-- 发布按钮 -->
<view class="publish-fab" @click="onPublishClick">
<uni-icons type="plus" size="30" color="#fff"></uni-icons>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, reactive, computed, onMounted } from 'vue'
import MomentCard from './components/MomentCard.uvue'
import {IMoment,ITab} from '@/types/dynamicType.uts'
import Api from '@/common/api-service.uts'
// 响应式数据
const activeTab = ref(0)
const activeHotTag = ref(0)
const refreshing = ref(false)
const loading = ref(false)
const noMore = ref(false)
const page = ref(1)
const pageSize = 10
const moments = reactive<IMoment[]>([])
const scopes = ['recommend', 'follow', 'nearby', 'hot']
const nearbyCount = computed(() => moments.length)
// 标签页数据
const tabs = reactive<ITab[]>([
{ name: '推荐', badge: 0 },
{ name: '关注', badge: 3 },
{ name: '附近', badge: 12 },
{ name: '热门', badge: 0 }
])
// 热门标签
const hotTags = reactive(['全部', '生活', '情感', '旅行', '美食', '游戏'])
// 计算属性
const pinnedMoments = computed<IMoment[]>(() => moments.filter(m => m.isPinned))
const recommendMoments = computed<IMoment[]>(() => moments)
const followMoments = computed<IMoment[]>(() => moments)
const nearbyMoments = computed<IMoment[]>(() => moments)
const hotMoments = computed<IMoment[]>(() => moments)
const loadStatus = computed(() => {
if (loading.value) return 'loading'
if (noMore.value) return 'noMore'
return 'more'
})
// 加载数据
const loadData = (reset : boolean = false) => {
if (loading.value) return
loading.value = true
const p = reset ? 1 : page.value
Api.moment.list({ scope: scopes[activeTab.value], page: p, page_size: pageSize })
.then((res : UTSJSONObject) => {
const list = (res.get('list') as Array<IMoment>) ?? []
if (reset) {
moments.splice(0, moments.length)
page.value = 1
}
list.forEach((m : IMoment) => moments.push(m))
page.value = p + 1
noMore.value = list.length < pageSize
})
.catch(() => {})
.finally(() => { loading.value = false })
}
// 页面加载
onMounted(() => { loadData(true) })
onShow(() => { loadData(true) })
// 切换标签页
const switchTab = (index : number) => {
activeTab.value = index
page.value = 1
noMore.value = false
loadData(true)
}
// 切换热门标签
const switchHotTag = (index : number) => {
activeHotTag.value = index
console.log('切换热门标签:', hotTags[index])
// 这里应该根据标签筛选热门动态
}
// 点击搜索
const onSearchClick = () => {
console.log('点击搜索')
uni.navigateTo({
url: '/pages/search/search'
})
}
// 点击发布
const onPublishClick = () => {
console.log('点击发布')
uni.navigateTo({
url: '/pages/moment/publish'
})
}
// 点赞动态
const onLike = (momentId : number) => {
const m = moments.find(x => x.id === momentId)
if (! m) return
const willLike = ! m.isLiked
m.isLiked = willLike
m.likes += willLike ? 1 : -1
Api.moment.like({ dynamic_id: momentId })
.catch(() => { m.isLiked = !willLike; m.likes += willLike ? -1 : 1 })
}
// 评论动态
const onComment = (momentId : number) => {
uni.navigateTo({ url: `/pages/moment/details?id=${momentId}` })
}
// 分享动态
const onShare = (momentId : number) => {
console.log('分享动态:', momentId)
uni.showActionSheet({
itemList: ['分享给好友', '分享到朋友圈', '复制链接', '保存图片'],
success: (res) => {
console.log('选择了分享方式:', res.tapIndex)
uni.showToast({
title: '分享成功',
icon: 'success',
duration: 2000
})
}
})
}
// 更多操作
const onMore = (momentId : number) => {
console.log('更多操作:', momentId)
uni.showActionSheet({
itemList: ['举报', '不感兴趣', '屏蔽用户', '保存到相册'],
success: (res) => {
const actions = ['report', 'notInterested', 'block', 'save']
const action = actions[res.tapIndex]
console.log('选择了:', action)
if (action === 'report') {
uni.navigateTo({
url: '/pages/report/report?momentId=' + momentId
})
} else {
uni.showToast({
title: '操作成功',
icon: 'success',
duration: 1500
})
}
}
})
}
// 下拉刷新
const onRefresh = () => {
refreshing.value = true
page.value = 1
loadData(true)
setTimeout(() => { refreshing.value = false }, 600)
}
// 上拉加载更多
const onLoadMore = () => {
if (loading.value || noMore.value) return
loadData(false)
}
// 发现好友
const findFriends = () => {
console.log('去发现好友')
uni.switchTab({
url: '/pages/home/home'
})
}
</script>
<style>
.title {
font-size: 36rpx;
font-weight: bold;
color: #333;
}
.header-right {
display: flex;
flex-flow: row nowrap;
}
.icon-btn {
width: 60rpx;
height: 60rpx;
display: flex;
justify-content: center;
align-items: center;
border-radius: 50%;
background-color: #f8f8f8;
}
/* 分类标签 */
.category-tabs {
display: flex;
flex-flow: row nowrap;
padding: 20rpx 0;
background-color: #fff;
border-bottom: 1rpx solid #eee;
}
.tab-item {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
margin: 0 30rpx;
padding: 10rpx 0;
}
.tab-text {
font-size: 30rpx;
color: #666;
}
.tabactive {
color: #FF6B6B;
font-weight: bold;
}
.badge {
position: absolute;
top: 0;
right: 0;
height: 32rpx;
text-align: center;
background-color: #FF6B6B;
color: #fff;
font-size: 20rpx;
border-radius: 16rpx;
transform: translate(50%, -50%);
}
/* 动态列表 */
.moment-list {
flex: 1;
padding: 0 20rpx 20rpx;
}
.section-title {
font-size: 28rpx;
color: #999;
padding: 20rpx 0 10rpx 20rpx;
}
.pinned-section .section-title {
color: #FF9500;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 0;
background-color: #fff;
border-radius: 20rpx;
margin: 20rpx;
}
.empty-text {
font-size: 28rpx;
color: #999;
margin: 20rpx 0 30rpx;
}
.empty-btn {
background: linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%);
color: #fff;
padding: 20rpx 50rpx;
border-radius: 40rpx;
font-size: 28rpx;
border: none;
}
.empty-btn::after {
border: none;
}
/* 附近动态头部 */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx 0 10rpx 20rpx;
}
.section-subtitle {
font-size: 24rpx;
color: #999;
margin-right: 20rpx;
}
/* 热门标签 */
.hot-tags {
display: flex;
flex-flow: row nowrap;
padding: 10rpx;
}
.hot-tag {
padding: 10rpx;
background-color: #f8f8f8;
border-radius: 30rpx;
font-size: 24rpx;
color: #666;
transition: all 0.3s ease;
}
.hot-tag.active {
background: linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%);
color: #fff;
}
/* 加载状态 */
.load-status {
padding: 30rpx 0;
display: flex;
justify-content: center;
}
/* 发布按钮 */
.publish-fab {
position: fixed;
right: 40rpx;
bottom: 140rpx;
width: 100rpx;
height: 100rpx;
background: linear-gradient(to bottom right , #FF6B6B , #FF8E53);
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
box-shadow: 0 10rpx 30rpx rgba(255, 107, 107, 0.3);
z-index: 100;
}
</style>
@@ -0,0 +1,655 @@
// pages/dynamic/publish.uts
<template>
<view class="page">
<!-- 顶部导航栏 -->
<view class="page-nav">
<view class="page-nav-left" @click="handleCancel">
<text class="cancel-text">取消</text>
</view>
<view class="page-nav-center">
<text class="nav-title">发布动态</text>
</view>
<view class="page-nav-right">
<button class="publish-btn" :class="{ active: canPublish }" @click="handlePublish">发布</button>
</view>
</view>
<!-- 内容输入区 -->
<view class="content-area">
<textarea class="content-input" placeholder="记录这一刻,晒给懂你的人" v-model="content" maxlength="250"
@input="updateCharCount"></textarea>
<text class="char-count">{{ charCount }}/250</text>
</view>
<!-- 图片上传区 -->
<view class="media-area">
<view class="upload-grid">
<view v-for="(file, index) in files" :key="index" class="grid-item">
<image :src="file.path" class="grid-image" mode="aspectFill" />
<view class="delete-btn" @click="removeImage(index)">
<uni-icons type="closeempty" color="#fff"></uni-icons>
</view>
</view>
<view v-if="files.length < 9" class="grid-item add-item" @click="chooseMedia">
<!-- <view class="add-icon">+</view> -->
<uni-icons type="plusempty" size="48"></uni-icons>
</view>
</view>
</view>
<!-- 功能选项区 -->
<view class="function-area">
<view class="function-item" @click="chooseLocation">
<view class="item-left">
<image src="/static/icons/location.png" class="item-icon" />
<text class="item-label">所在位置</text>
</view>
<view class="item-right">
<text class="item-value">{{ location ?? '未选择' }}</text>
<image src="/static/icons/arrow-right.png" class="arrow-icon" />
</view>
</view>
<view class="function-item" @click="showTopicDialog">
<view class="item-left">
<image src="/static/icons/topic.png" class="item-icon" />
<text class="item-label">添加话题</text>
</view>
<view class="item-right">
<text class="item-value">{{ topics.length > 0 ? `#${topics.join(' #')}` : '选择话题会获得更多互动哦' }}</text>
<image src="/static/icons/arrow-right.png" class="arrow-icon" />
</view>
</view>
<view class="function-item voice-item" @click="toggleRecording">
<view class="item-left">
<image :src="isRecording ? '/static/icons/voice-recording.png' : '/static/icons/voice.png'"
class="item-icon" />
<text class="item-label">{{ isRecording ? '正在录音...' : '语音描述' }}</text>
</view>
<view class="item-right">
<view v-if="audioPath" class="audio-preview">
<image src="/static/icons/play.png" class="play-icon" @click.stop="playAudio" />
<text class="audio-duration">{{ audioDuration }}"</text>
</view>
<image v-else src="/static/icons/arrow-right.png" class="arrow-icon" />
</view>
</view>
</view>
<!-- 话题选择弹窗 -->
<view v-if="showTopicSelector" class="topic-dialog">
<view class="dialog-mask" @click="hideTopicDialog"></view>
<view class="dialog-content">
<view class="dialog-header">
<text class="dialog-title">选择话题</text>
<text class="dialog-close" @click="hideTopicDialog">×</text>
</view>
<view class="topic-list">
<view v-for="(topic, index) in availableTopics" :key="index" class="topic-item"
@click="toggleTopic(topic)">
<text class="topic-text">#{{ topic }}</text>
<view class="topic-check" v-if="topics.includes(topic)">
<image src="/static/icons/checked.png" class="check-icon" />
</view>
</view>
</view>
<view class="dialog-footer">
<button class="confirm-btn" @click="hideTopicDialog">完成</button>
</view>
</view>
</view>
<!-- 位置选择弹窗 -->
<view v-if="showLocationSelector" class="location-dialog">
<view class="dialog-mask" @click="hideLocationDialog"></view>
<view class="dialog-content">
<view class="dialog-header">
<text class="dialog-title">选择位置</text>
<text class="dialog-close" @click="hideLocationDialog">×</text>
</view>
<view class="search-box">
<input v-model="locationSearch" placeholder="搜索地点" class="search-input" />
</view>
<view class="location-list">
<view v-for="(loc, index) in filteredLocations" :key="index" class="location-item"
@click="selectLocation(loc)">
<text class="location-name">{{ loc.name }}</text>
<text class="location-address">{{ loc.address }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import httpApi, { type IFileInfo } from '@/utlis/http-api'
import type { IUploadOption } from '@/types/http.uts'
import Api from '@/common/api-service.uts'
// 响应式数据
type locationItem = { name : string, address : string }
const content = ref<string>('')
const charCount = ref<number>(0)
const location = ref<string>('')
const topics = ref<Array<string>>([])
const audioPath = ref<string>('')
const audioDuration = ref<number>(0)
const isRecording = ref<boolean>(false)
const showTopicSelector = ref<boolean>(false)
const showLocationSelector = ref<boolean>(false)
const locationSearch = ref<string>('')
const recorderManager = ref(null as RecorderManager | null)
const files = ref<IFileInfo[]>([])
// 可用话题列表
const availableTopics = ref<Array<string>>([
'美食分享', '厨房日记', '烹饪技巧', '家常菜', '烘焙时光',
'美食探店', '食材百科', '健康饮食', '快手菜', '创意料理'
])
// 位置列表
const locations = ref<Array<locationItem>>([
{ name: '北京市朝阳区三里屯', address: '北京市朝阳区三里屯街道' },
{ name: '上海市黄浦区外滩', address: '上海市黄浦区外滩街道' },
{ name: '广州市天河区珠江新城', address: '广州市天河区珠江新城' },
{ name: '深圳市南山区科技园', address: '深圳市南山区科技园' },
{ name: '成都市锦江区春熙路', address: '成都市锦江区春熙路' }
])
// 计算属性
const canPublish = computed(() => {
return content.value.trim().length > 0 || files.value.length > 0 || audioPath.value !== ''
})
const filteredLocations = computed<locationItem[]>(() => {
if (locationSearch.value != null) return locations.value
return locations.value.filter(loc =>
loc.name.includes(locationSearch.value) || loc.address.includes(locationSearch.value)
)
})
// 生命周期钩子
onLoad(() => {
// 页面加载时的初始化操作
//updateCharCount()
})
// 方法定义
function updateCharCount() {
charCount.value = content.value.length
}
function chooseMedia() {
// #ifndef WEB
uni.chooseMedia({
count: 9 - files.value.length,
mediaType: ['image', 'video'],
sourceType: ['album', 'camera'],
success: (res) => {
console.log(res)
res.tempFiles.forEach((path : ChooseMediaTempFile) => {
files.value.push({
index: files.value.length ,
name: 'file' + (files.value.length ),
path: path.tempFilePath,
} as IFileInfo)
})
}
})
// #endif
// #ifdef WEB
uni.chooseImage({
count: 9 - files.value.length,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (res) => {
console.log(res)
res.tempFilePaths.forEach((file) => {
files.value.push({
index: files.value.length ,
name: 'file' + (files.value.length ),
path: file,
} )
})
}
})
// #endif
}
function removeImage(index : number) {
files.value.splice(index, 1)
}
function chooseLocation() {
showLocationSelector.value = true
}
function showTopicDialog() {
showTopicSelector.value = true
}
function hideTopicDialog() {
showTopicSelector.value = false
}
function hideLocationDialog() {
showLocationSelector.value = false
locationSearch.value = ''
}
function selectLocation(loc : locationItem) {
location.value = loc.name
hideLocationDialog()
}
function toggleTopic(topic : string) {
const index = topics.value.indexOf(topic)
if (index > -1) {
topics.value.splice(index, 1)
} else {
topics.value.push(topic)
}
}
let innerAudioContext : any | null = null
function startRecording() {
isRecording.value = true
audioPath.value = ''
audioDuration.value = 0
// 创建录音管理器
recorderManager.value = uni.getRecorderManager();
// 录音结束事件
recorderManager.value!.onStop((res) => {
console.log(res);
// isRecording.value = false
// audioPath.value = res.tempFilePath
// audioDuration.value = Math.floor(res.duration as number / 1000) // 转换为秒
})
// 开始录音
recorderManager.value!.start({
duration: 60000, // 最长60秒
sampleRate: 44100,
numberOfChannels: 1,
encodeBitRate: 192000,
format: 'mp3'
})
}
function stopRecording() {
if (recorderManager != null) {
recorderManager.value!.stop()
}
}
function toggleRecording() {
if (isRecording.value) {
stopRecording()
} else {
startRecording()
}
}
function playAudio() {
// if (!innerAudioContext) {
// innerAudioContext = uni.createInnerAudioContext()
// }
// if (innerAudioContext.paused) {
// innerAudioContext.src = audioPath.value
// innerAudioContext.play()
// } else {
// innerAudioContext.pause()
// }
}
function handleCancel() {
uni.navigateBack()
}
async function handlePublish() {
if (!canPublish.value) return
uni.showLoading({ title: '发布中...', mask: true })
try {
// 1. 上传图片/视频,拿到可访问 URL
let media : UTSJSONObject[] = []
let hasVideo = false
if (files.value.length > 0) {
const uploaded = await httpApi.uploadMultipleFilesParallel({
url: '/wxchat/api/moment/upload'
} as IUploadOption, files.value)
uploaded.forEach((f) => {
const p = f.path
const isVideo = p.endsWith('.mp4') || p.endsWith('.mov') || p.endsWith('.m4v') || p.endsWith('.avi')
if (isVideo) hasVideo = true
media.push({ type: isVideo ? 2 : 1, url: p } as UTSJSONObject)
})
}
// 2. 计算动态类型:1 文字 / 2 图片 / 3 视频
let type = 1
if (hasVideo) {
type = 3
} else if (media.length > 0) {
type = 2
}
// 3. 提交发布
await Api.moment.create({
content: content.value.trim(),
type: type,
is_public: 1,
location: location.value,
media: media,
hashtags: topics.value
})
uni.hideLoading()
uni.showToast({ title: '发布成功', icon: 'success' })
setTimeout(() => {
uni.navigateBack()
}, 1200)
} catch (error) {
uni.hideLoading()
uni.showToast({ title: '发布失败', icon: 'none' })
console.log('发布失败:', error)
}
}
</script>
<style lang="scss">
/* 内容输入区 */
.content-area {
margin-top: 100rpx;
position: relative;
padding: 16px;
background-color: #fff;
margin-bottom: 8px;
.content-input {
width: 100%;
min-height: 120px;
font-size: 16px;
line-height: 1.5;
color: #333;
border: none;
}
.char-count {
position: absolute;
right: 16px;
bottom: 8px;
font-size: 12px;
color: #999;
}
}
/* 图片上传区 */
.media-area {
padding: 16px;
background-color: #fff;
margin-bottom: 8px;
.upload-grid {
display: flex;
flex-flow: row wrap;
border-radius: 25rpx;
overflow: hidden;
background: none;
.grid-item {
position: relative;
border-radius: 4px;
margin: 3rpx;
overflow: hidden;
background-color: #f5f5f5;
flex: 0 0 222rpx;
height: 222rpx;
background-color: #f5f5f5;
overflow: hidden;
position: relative;
box-sizing: border-box;
.grid-image {
width: 100%;
height: 100%;
transition: transform 0.3s;
}
.delete-btn {
position: absolute;
top: 10rpx;
right: 10rpx;
width: 48rpx;
height: 48rpx;
background-color: rgba(0, 0, 0, 0.5);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
&.add-item {
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed #ccc;
.add-icon {
font-size: 32px;
color: #999;
}
}
}
}
}
/* 功能选项区 */
.function-area {
background-color: #fff;
margin-bottom: 8px;
.function-item {
display: flex;
flex-flow: row;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
.item-left {
display: flex;
flex-flow: row;
align-items: center;
.item-icon {
width: 20px;
height: 20px;
margin-right: 12px;
}
.item-label {
font-size: 16px;
color: #333;
}
}
.item-right {
display: flex;
flex-flow: row;
align-items: center;
.item-value {
font-size: 14px;
color: #999;
margin-right: 8px;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.arrow-icon {
width: 16px;
height: 16px;
}
.audio-preview {
display: flex;
flex-flow: row;
align-items: center;
.play-icon {
width: 20px;
height: 20px;
margin-right: 8px;
}
.audio-duration {
font-size: 14px;
color: #666;
}
}
}
&.voice-item {
.item-label {
color: #ff2442;
}
}
}
}
/* 弹窗样式 */
.topic-dialog,
.location-dialog {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
.dialog-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
}
.dialog-content {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background-color: #fff;
border-radius: 12px 12px 0 0;
display: flex;
flex-direction: column;
.dialog-header {
display: flex;
flex-flow: row;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid #f0f0f0;
.dialog-title {
font-size: 18px;
font-weight: 600;
color: #333;
}
.dialog-close {
font-size: 24px;
color: #999;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
}
.topic-list,
.location-list {
flex: 1;
padding: 16px;
.topic-item,
.location-item {
display: flex;
flex-flow: row;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
.topic-text {
font-size: 16px;
color: #333;
}
.topic-check {
width: 20px;
height: 20px;
border-radius: 50%;
background-color: #ff2442;
display: flex;
align-items: center;
justify-content: center;
.check-icon {
width: 12px;
height: 8px;
}
}
.location-name {
font-size: 16px;
color: #333;
margin-bottom: 4px;
}
.location-address {
font-size: 12px;
color: #999;
}
}
}
.search-box {
padding: 16px;
border-bottom: 1px solid #f0f0f0;
.search-input {
width: 100%;
height: 36px;
padding: 0 12px;
border-radius: 18px;
background-color: #f5f5f5;
font-size: 14px;
}
}
.dialog-footer {
padding: 16px;
border-top: 1px solid #f0f0f0;
.confirm-btn {
width: 100%;
height: 44px;
background-color: #ff2442;
color: #fff;
font-size: 16px;
border-radius: 22px;
border: none;
}
}
}
}
</style>