1.0
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<div class="moment-card">
|
||||
<!-- 头部:头像 + 用户名 + 时间 -->
|
||||
<div class="moment-header">
|
||||
<n-avatar :size="40" round :style="{ background: 'var(--color-primary)' }">
|
||||
{{ (moment.nickname || moment.username || '?')[0] }}
|
||||
</n-avatar>
|
||||
<div class="header-info">
|
||||
<span class="author-name">{{ moment.nickname || moment.username }}</span>
|
||||
<span class="post-time">{{ formatTime(moment.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容 -->
|
||||
<div class="moment-content">{{ moment.content }}</div>
|
||||
|
||||
<!-- 图片 -->
|
||||
<div v-if="moment.images && moment.images.length > 0" class="moment-images" :class="`grid-${Math.min(moment.images.length, 3)}`">
|
||||
<div v-for="(img, i) in moment.images" :key="i" class="image-item">
|
||||
<img :src="img" :alt="`图片${i + 1}`" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<div class="moment-actions">
|
||||
<span class="action-btn" :class="{ liked: moment.is_liked }" @click="$emit('toggle-like', moment.id)">
|
||||
{{ moment.is_liked ? '❤️' : '🤍' }} {{ moment.like_count || '' }}
|
||||
</span>
|
||||
<span class="action-btn" @click="showComments = !showComments">
|
||||
💬 {{ moment.comment_count || '' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 评论区 -->
|
||||
<div v-if="showComments" class="comments-section">
|
||||
<div v-if="comments.length > 0" class="comments-list">
|
||||
<div v-for="comment in comments" :key="comment.id" class="comment-item">
|
||||
<span class="comment-author">{{ comment.nickname || comment.username }}</span>
|
||||
<span v-if="comment.reply_to_username" class="comment-reply">
|
||||
回复 <span class="comment-author">{{ comment.reply_to_username }}</span>
|
||||
</span>
|
||||
<span class="comment-text">:{{ comment.content }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="comment-input">
|
||||
<n-input v-model:value="commentText" placeholder="写评论..." size="small"
|
||||
@keydown.enter.prevent="submitComment" />
|
||||
<n-button size="tiny" type="primary" :disabled="!commentText.trim()" @click="submitComment">发送</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { momentsApi } from '@/api/moments'
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
dayjs.locale('zh-cn')
|
||||
|
||||
const props = defineProps<{ moment: any }>()
|
||||
const emit = defineEmits<{
|
||||
'toggle-like': [momentId: string]
|
||||
'comment': [momentId: string, content: string]
|
||||
}>()
|
||||
|
||||
const showComments = ref(false)
|
||||
const comments = ref<any[]>([])
|
||||
const commentText = ref('')
|
||||
|
||||
watch(showComments, async (val) => {
|
||||
if (val && comments.value.length === 0) {
|
||||
try {
|
||||
const { data } = await momentsApi.getComments(props.moment.id)
|
||||
comments.value = data
|
||||
} catch {}
|
||||
}
|
||||
})
|
||||
|
||||
async function submitComment() {
|
||||
if (!commentText.value.trim()) return
|
||||
try {
|
||||
const { data } = await momentsApi.addComment(props.moment.id, commentText.value.trim())
|
||||
comments.value.push(data)
|
||||
emit('comment', props.moment.id, commentText.value.trim())
|
||||
commentText.value = ''
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function formatTime(time: string) {
|
||||
const d = dayjs(time)
|
||||
const now = dayjs()
|
||||
const diffMin = now.diff(d, 'minute')
|
||||
if (diffMin < 1) return '刚刚'
|
||||
if (diffMin < 60) return `${diffMin}分钟前`
|
||||
const diffHour = now.diff(d, 'hour')
|
||||
if (diffHour < 24) return `${diffHour}小时前`
|
||||
const diffDay = now.diff(d, 'day')
|
||||
if (diffDay < 7) return `${diffDay}天前`
|
||||
return d.format('MM/DD')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.moment-card {
|
||||
background: var(--color-surface); border-radius: 12px; padding: 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.moment-header { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
|
||||
.header-info { display: flex; flex-direction: column; }
|
||||
.author-name { font-weight: 600; font-size: 14px; }
|
||||
.post-time { font-size: 11px; color: var(--color-text-hint); }
|
||||
|
||||
.moment-content {
|
||||
font-size: 14px; line-height: 1.6; color: var(--color-text-primary);
|
||||
margin-bottom: 10px; white-space: pre-wrap; word-break: break-word;
|
||||
}
|
||||
|
||||
.moment-images { display: grid; gap: 6px; margin-bottom: 10px; border-radius: 8px; overflow: hidden; }
|
||||
.moment-images.grid-1 { grid-template-columns: 1fr; max-width: 280px; }
|
||||
.moment-images.grid-2 { grid-template-columns: 1fr 1fr; }
|
||||
.moment-images.grid-3 { grid-template-columns: 1fr 1fr 1fr; }
|
||||
.image-item { aspect-ratio: 1; overflow: hidden; border-radius: 6px; cursor: pointer; }
|
||||
.image-item img { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
.moment-actions {
|
||||
display: flex; gap: 16px; padding-top: 8px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.action-btn {
|
||||
font-size: 13px; cursor: pointer; color: var(--color-text-secondary);
|
||||
display: flex; align-items: center; gap: 3px; transition: color 0.2s;
|
||||
}
|
||||
.action-btn:hover { color: var(--color-primary); }
|
||||
.action-btn.liked { color: #E53935; }
|
||||
|
||||
.comments-section { margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--color-border); }
|
||||
.comments-list { margin-bottom: 8px; }
|
||||
.comment-item {
|
||||
font-size: 13px; padding: 4px 0; line-height: 1.5;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.comment-author { color: var(--color-primary); font-weight: 500; cursor: pointer; }
|
||||
.comment-reply { color: var(--color-text-hint); }
|
||||
.comment-text { color: var(--color-text-primary); }
|
||||
|
||||
.comment-input { display: flex; gap: 6px; margin-top: 6px; }
|
||||
</style>
|
||||
@@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<div class="moments-feed">
|
||||
<div class="panel-header">
|
||||
<h3 class="panel-title">朋友圈</h3>
|
||||
</div>
|
||||
<div class="feed-content">
|
||||
<!-- 发布入口 -->
|
||||
<div class="compose-trigger" @click="showCompose = true">
|
||||
<span class="compose-icon">✏️</span>
|
||||
<span>发布新动态...</span>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="momentsStore.isLoading && momentsStore.feed.length === 0" class="loading">
|
||||
加载中...
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="momentsStore.feed.length === 0" class="empty-moments">
|
||||
<div style="font-size: 48px">🌿</div>
|
||||
<p style="color: var(--color-text-secondary)">还没有动态</p>
|
||||
<p style="font-size: 13px; color: var(--color-text-hint)">发布第一条动态吧</p>
|
||||
</div>
|
||||
|
||||
<!-- 动态列表 -->
|
||||
<div v-else class="moment-list">
|
||||
<MomentCard
|
||||
v-for="moment in momentsStore.feed"
|
||||
:key="moment.id"
|
||||
:moment="moment"
|
||||
@toggle-like="handleToggleLike"
|
||||
@comment="handleComment"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<div v-if="momentsStore.hasMore && momentsStore.feed.length > 0" class="load-more">
|
||||
<n-button text size="small" @click="momentsStore.fetchFeed()">加载更多</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 发布弹窗 -->
|
||||
<div v-if="showCompose" class="modal-overlay" @click.self="showCompose = false">
|
||||
<div class="compose-modal">
|
||||
<div class="modal-header">
|
||||
<h3>发布动态</h3>
|
||||
<span class="close-btn" @click="showCompose = false">✕</span>
|
||||
</div>
|
||||
<div class="compose-body">
|
||||
<n-input v-model:value="composeText" type="textarea" :rows="4"
|
||||
placeholder="分享你的想法..." maxlength="1000" show-count />
|
||||
<div class="compose-options">
|
||||
<div class="visibility-select">
|
||||
<span class="option-label">可见范围:</span>
|
||||
<n-button-group size="tiny">
|
||||
<n-button :type="composeVisibility === 'friends' ? 'primary' : 'default'" @click="composeVisibility = 'friends'">好友</n-button>
|
||||
<n-button :type="composeVisibility === 'public' ? 'primary' : 'default'" @click="composeVisibility = 'public'">公开</n-button>
|
||||
<n-button :type="composeVisibility === 'private' ? 'primary' : 'default'" @click="composeVisibility = 'private'">仅自己</n-button>
|
||||
</n-button-group>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="compose-footer">
|
||||
<n-button @click="showCompose = false">取消</n-button>
|
||||
<n-button type="primary" :loading="publishing" :disabled="!composeText.trim()" @click="publishMoment">发布</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useMomentsStore } from '@/stores/moments'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import MomentCard from './MomentCard.vue'
|
||||
|
||||
const momentsStore = useMomentsStore()
|
||||
const message = useMessage()
|
||||
|
||||
const showCompose = ref(false)
|
||||
const composeText = ref('')
|
||||
const composeVisibility = ref('friends')
|
||||
const publishing = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
momentsStore.fetchFeed(true)
|
||||
})
|
||||
|
||||
async function publishMoment() {
|
||||
if (!composeText.value.trim()) return
|
||||
publishing.value = true
|
||||
try {
|
||||
await momentsStore.createMoment(composeText.value.trim(), undefined, composeVisibility.value)
|
||||
message.success('动态发布成功')
|
||||
composeText.value = ''
|
||||
showCompose.value = false
|
||||
} catch {
|
||||
message.error('发布失败')
|
||||
} finally {
|
||||
publishing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleLike(momentId: string) {
|
||||
try {
|
||||
await momentsStore.toggleLike(momentId)
|
||||
} catch {
|
||||
message.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleComment(momentId: string, content: string) {
|
||||
try {
|
||||
await momentsStore.addComment(momentId, content)
|
||||
message.success('评论成功')
|
||||
} catch {
|
||||
message.error('评论失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.moments-feed { display: flex; flex-direction: column; height: 100%; }
|
||||
.panel-header { padding: 16px; border-bottom: 1px solid var(--color-border); }
|
||||
.panel-title { margin: 0; font-size: 16px; font-weight: 600; }
|
||||
.feed-content { flex: 1; overflow-y: auto; padding: 12px; }
|
||||
|
||||
.compose-trigger {
|
||||
display: flex; align-items: center; gap: 8px; padding: 12px 16px;
|
||||
background: var(--color-surface); border-radius: 10px;
|
||||
border: 1px dashed var(--color-border); cursor: pointer;
|
||||
color: var(--color-text-secondary); font-size: 14px; transition: all 0.2s; margin-bottom: 16px;
|
||||
}
|
||||
.compose-trigger:hover { border-color: var(--color-primary-lighter); color: var(--color-primary); background: var(--color-primary-lightest); }
|
||||
.compose-icon { font-size: 18px; }
|
||||
|
||||
.loading { text-align: center; padding: 40px; color: var(--color-text-hint); }
|
||||
.empty-moments { text-align: center; padding: 60px 20px; }
|
||||
|
||||
.moment-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
.load-more { text-align: center; padding: 12px; }
|
||||
|
||||
/* 发布弹窗 */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.4);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 1000;
|
||||
}
|
||||
.compose-modal {
|
||||
width: 500px; background: var(--color-surface); border-radius: 16px;
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,0.15);
|
||||
}
|
||||
.modal-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 20px 24px 16px; border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.modal-header h3 { margin: 0; font-size: 18px; }
|
||||
.close-btn { cursor: pointer; font-size: 18px; color: var(--color-text-hint); padding: 4px; }
|
||||
.close-btn:hover { color: var(--color-text-primary); }
|
||||
.compose-body { padding: 16px 24px; }
|
||||
.compose-options { margin-top: 12px; }
|
||||
.visibility-select { display: flex; align-items: center; gap: 8px; }
|
||||
.option-label { font-size: 13px; color: var(--color-text-secondary); }
|
||||
.compose-footer {
|
||||
display: flex; justify-content: flex-end; gap: 8px;
|
||||
padding: 16px 24px; border-top: 1px solid var(--color-border);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user