Files

655 lines
15 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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>