Compare commits
37 Commits
ada774a142
...
feat/info-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fb8a05f8d | ||
|
|
8d2337e5b7 | ||
|
|
d14084c2a1 | ||
|
|
d1d4481ff5 | ||
|
|
a47b0e2a7a | ||
|
|
7a332ba44d | ||
|
|
b79e1e8204 | ||
|
|
88f4220432 | ||
|
|
3c7e520062 | ||
|
|
f256df8f94 | ||
|
|
082cd011d2 | ||
|
|
cd1595373b | ||
|
|
9a28a0929d | ||
|
|
179abe01b0 | ||
|
|
26d3536cdf | ||
|
|
68ad3b0091 | ||
|
|
4f7613f71c | ||
|
|
628c02752c | ||
|
|
f3baf1447a | ||
|
|
aa88eef08e | ||
|
|
1e5bbbbf9e | ||
|
|
f95fe36b5a | ||
|
|
8f11ed9333 | ||
|
|
3ae62897b6 | ||
|
|
21bfa3efd8 | ||
|
|
d325329af4 | ||
|
|
0383ad6be2 | ||
|
|
d129f1d38e | ||
|
|
829f9a07d4 | ||
|
|
1173a3e3a1 | ||
|
|
a98f82c113 | ||
|
|
0e0d9604e1 | ||
|
|
d4478cccd8 | ||
|
|
36dbf05c11 | ||
|
|
03cb2edf5e | ||
|
|
65cef99610 | ||
|
|
27c244f3cd |
5
App.vue
@@ -1,6 +1,7 @@
|
||||
<script>
|
||||
import { useUserStore } from "./stores/user";
|
||||
import { userOpenApp } from "./api/auth";
|
||||
import { refreshMessageTabBarBadge } from "./utils/tabBar";
|
||||
|
||||
const openApp = async () => {
|
||||
try {
|
||||
@@ -22,10 +23,14 @@ export default {
|
||||
onLaunch() {
|
||||
const userStore = useUserStore();
|
||||
userStore.loadFromStorage();
|
||||
refreshMessageTabBarBadge();
|
||||
if (userStore.userInfo.id) {
|
||||
openApp();
|
||||
}
|
||||
},
|
||||
onShow() {
|
||||
refreshMessageTabBarBadge();
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
11
api/mine.js
@@ -1,5 +1,16 @@
|
||||
import { request } from "@/utils/request.js";
|
||||
|
||||
/**
|
||||
* @description: 获取当前用户参与的话题数据
|
||||
* @return {participant: number, comment: number, liked: number, label: string[]}
|
||||
*/
|
||||
export const fetchUserNums = async () => {
|
||||
return request({
|
||||
url: "/api/rating/personal/nums",
|
||||
method: "GET",
|
||||
});
|
||||
};
|
||||
|
||||
export const sendFeedback = async (data) => {
|
||||
return request({
|
||||
url: "/api/common/feedback",
|
||||
|
||||
60
api/notification.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import { request } from "@/utils/request.js";
|
||||
|
||||
export const RatingNotificationTypeEnum = {
|
||||
TOPIC_SUBMIT: 'topic_submit',
|
||||
TOPIC_APPROVED: 'topic_approved',
|
||||
TOPIC_REJECTED: 'topic_rejected',
|
||||
ITEM_SUBMIT: 'item_submit',
|
||||
ITEM_APPROVED: 'item_approved',
|
||||
ITEM_REJECTED: 'item_rejected',
|
||||
COMMENT: 'comment',
|
||||
COMMENT_LIKE: 'comment_like',
|
||||
SYSTEM: 'system',
|
||||
}
|
||||
/**
|
||||
* @description: 获取当前用户的消息列表
|
||||
* @param {page: number, type?: RatingNotificationTypeEnum} data
|
||||
* @return
|
||||
*/
|
||||
export const fetchNotificationList = async (data) => {
|
||||
return request({
|
||||
url: "/api/rating/personal/notification/list",
|
||||
method: "GET",
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description: 获取当前用户未读消息数量
|
||||
* @return
|
||||
*/
|
||||
|
||||
export const fetchUnreadCount = async () => {
|
||||
return request({
|
||||
url: "/api/rating/personal/notification/unread_count",
|
||||
method: "GET",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description: 标记指定消息为已读
|
||||
* @param {id: number} data
|
||||
* @return
|
||||
*/
|
||||
export const markAsRead = async (id) => {
|
||||
return request({
|
||||
url: "/api/rating/personal/notification/read/" + id,
|
||||
method: "Patch",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description: 标记所有消息为已读
|
||||
* @return
|
||||
*/
|
||||
export const markAllAsRead = async () => {
|
||||
return request({
|
||||
url: "/api/rating/personal/notification/read_all",
|
||||
method: "Patch",
|
||||
});
|
||||
};
|
||||
87
api/pk.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import { request } from "@/utils/request.js";
|
||||
|
||||
// 获取随机一组PK主题
|
||||
export const fetchRandomTopicList = async () => {
|
||||
return request({
|
||||
url: "/api/rating/pk/random",
|
||||
method: "GET",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 用户投票
|
||||
* @param { pkId, itemId }
|
||||
*/
|
||||
export const vote = async (data) => {
|
||||
return request({
|
||||
url: "/api/rating/pk/vote",
|
||||
method: "POST",
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取PK 评论列表
|
||||
* @param { pkId: string, page: number, orderBy: string }
|
||||
* @param { orderBy: string } 排序字段,可选值:'hot' | 'new',
|
||||
*/
|
||||
export const fetchCommentList = async (data) => {
|
||||
return request({
|
||||
url: "/api/rating/pk/comment/list",
|
||||
method: "GET",
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取PK 评论回复
|
||||
* @param { pkId: string, rootCommentId: string, page: number }
|
||||
*/
|
||||
export const fetchCommentReplyList = async (data) => {
|
||||
return request({
|
||||
url: "/api/rating/pk/comment/replies",
|
||||
method: "GET",
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 发布PK 评论
|
||||
* @param { pkId: string, content: string, parentCommentId?: string, replyToCommentId?: string }
|
||||
* @param { parentCommentId?: string } 父评论ID,回复时必填
|
||||
* @param { replyToCommentId?: string } 回复的评论ID,回复时必填
|
||||
*/
|
||||
export const publishComment = async (data) => {
|
||||
return request({
|
||||
url: "/api/rating/pk/comment",
|
||||
method: "POST",
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* PK 评论 点赞
|
||||
* @param { commentId: string }
|
||||
*/
|
||||
export const likeComment = async (data) => {
|
||||
return request({
|
||||
url: "/api/rating/pk/comment/like",
|
||||
method: "POST",
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取用户 pk 列表
|
||||
* @param { page: number }
|
||||
*/
|
||||
export const fetchUserPkList = async (data) => {
|
||||
return request({
|
||||
url: "/api/rating/personal/pk/list",
|
||||
method: "GET",
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -40,3 +40,11 @@ export const fetchTopicRatingItems = async (topicId, page = 1) => {
|
||||
method: "GET",
|
||||
});
|
||||
};
|
||||
|
||||
// 获取当前用户参与的话题列表
|
||||
export const fetchUserTopics = async (page = 1) => {
|
||||
return request({
|
||||
url: `/api/rating/personal/topic/list?page=${page}`,
|
||||
method: "GET",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ export const topicItemScore = async (data) => {
|
||||
};
|
||||
|
||||
// 评论
|
||||
// data = { topicId, itemId, content, parentCommentId?, replyToCommentId? }
|
||||
export const topicItemComment = async (data) => {
|
||||
return request({
|
||||
url: "/api/rating/topic/comment/item",
|
||||
@@ -29,17 +30,39 @@ export const topicItemCommentLike = async (data) => {
|
||||
};
|
||||
|
||||
// 获取评论列表
|
||||
export const topicItemCommentList = async (topicId, itemId, page = 1, orderBy = 1) => {
|
||||
export const topicItemCommentList = async (topicId, itemId, page = 1, orderBy = 'hot') => {
|
||||
return request({
|
||||
url: `/api/rating/topic/comment/item/list?topicId=${topicId}&itemId=${itemId}&page=${page}&orderBy=${orderBy}`,
|
||||
method: "GET",
|
||||
});
|
||||
};
|
||||
|
||||
/*
|
||||
* 获取评论回复列表
|
||||
* @param {rootCommentId: string, page: number} data
|
||||
*/
|
||||
export const topicItemCommentReplyList = async (data) => {
|
||||
return request({
|
||||
url: "/api/rating/topic/comment/replies",
|
||||
method: "GET",
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
// 获取评分项详情
|
||||
export const fetchTopicRatingItems = async (itemId) => {
|
||||
return request({
|
||||
url: `/api/rating/topic/item/detail/${itemId}`,
|
||||
method: "GET",
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
// 添加评分项
|
||||
// data = { topicId: 'xxx', name: 'xxx', avatarUrl: 'xxx' }
|
||||
export const topicItemAdd = async (data) => {
|
||||
return request({
|
||||
url: "/api/rating/topic/item/add",
|
||||
method: "POST",
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -20,21 +20,28 @@
|
||||
class="comment-input"
|
||||
type="text"
|
||||
v-model="commentText"
|
||||
placeholder="说点什么吧..."
|
||||
:disabled="sending"
|
||||
:placeholder="inputPlaceholder"
|
||||
placeholder-class="input-placeholder"
|
||||
/>
|
||||
<button class="send-btn" @tap="handleSend">发送</button>
|
||||
<button class="send-btn" :class="{ 'is-disabled': sending }" :disabled="sending" @tap="handleSend">
|
||||
{{ sending ? '发送中...' : '发送' }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
initialAction: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
sending: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
@@ -48,6 +55,7 @@ watch(() => props.initialAction, (newVal) => {
|
||||
});
|
||||
|
||||
const commentText = ref('');
|
||||
const inputPlaceholder = computed(() => '说点什么吧...');
|
||||
|
||||
const actions = [
|
||||
{ label: '拉', value: 'terrible', emoji: '👎', score: 2, scoreType:1 },
|
||||
@@ -62,19 +70,28 @@ const handleAction = (action) => {
|
||||
};
|
||||
|
||||
const handleSend = () => {
|
||||
if (!commentText.value.trim()) {
|
||||
const content = commentText.value.trim();
|
||||
if (!content) {
|
||||
uni.showToast({
|
||||
title: '请输入评论内容',
|
||||
icon: 'none'
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (props.sending) return;
|
||||
emit('send-comment', {
|
||||
action: currentAction.value,
|
||||
content: commentText.value
|
||||
content
|
||||
});
|
||||
commentText.value = ''; // 发送后清空
|
||||
};
|
||||
|
||||
const resetComment = () => {
|
||||
commentText.value = '';
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
resetComment
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -162,7 +179,11 @@ const handleSend = () => {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.send-btn.is-disabled {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.send-btn::after {
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,80 +1,262 @@
|
||||
<template>
|
||||
<view class="comment-item">
|
||||
<image class="c-avatar" :src="comment.avatar" mode="aspectFill" />
|
||||
<view
|
||||
class="comment-item"
|
||||
:id="`comment-${localComment.id}`"
|
||||
:class="{ 'comment-item--anchored': localComment.id === props.activeAnchorId }"
|
||||
>
|
||||
<image class="c-avatar" :src="localComment.avatar" mode="aspectFill" />
|
||||
<view class="c-content-wrap">
|
||||
<view class="c-header">
|
||||
<view class="c-user-info">
|
||||
<text class="c-name">{{ comment.name }}</text>
|
||||
<!-- 用户态度徽章 -->
|
||||
<ActionBadge v-if="comment.userAction" :action="comment.userAction" />
|
||||
<view class="c-badge" v-if="comment.badge">
|
||||
<text class="b-icon">{{ comment.badgeIcon }}</text>
|
||||
<text class="b-text">{{ comment.badge }}</text>
|
||||
<text class="c-name">{{ localComment.name }}</text>
|
||||
<view
|
||||
v-if="localComment.choiceLabel"
|
||||
class="choice-tag"
|
||||
:class="localComment.choiceTone ? `choice-tag--${localComment.choiceTone}` : ''"
|
||||
>
|
||||
<text class="choice-tag-text">{{ localComment.choiceLabel }}</text>
|
||||
</view>
|
||||
<ActionBadge v-if="localComment.userAction" :action="localComment.userAction" />
|
||||
<view class="c-badge" v-if="localComment.badge">
|
||||
<text class="b-icon">{{ localComment.badgeIcon }}</text>
|
||||
<text class="b-text">{{ localComment.badge }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="c-like" @tap="handleLike">
|
||||
<text class="like-icon" :class="{ 'is-liked': isLiked }"></text>
|
||||
<text class="like-count" :class="{ 'liked-text': isLiked }">{{ currentLikes }}</text>
|
||||
<view class="c-like" @tap="handleLike()">
|
||||
<text class="like-icon" :class="{ 'is-liked': localComment.isLiked }"></text>
|
||||
<text class="like-count" :class="{ 'liked-text': localComment.isLiked }">{{ localComment.likes }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<text class="c-time">{{ localComment.time }}</text>
|
||||
<text class="c-text">{{ localComment.content }}</text>
|
||||
|
||||
<view class="c-actions">
|
||||
<text class="c-action-btn" @tap="emitReply(localComment)">回复</text>
|
||||
<text
|
||||
v-if="localComment.replyCount && !localComment.repliesLoaded && !localComment.repliesLoading"
|
||||
class="c-action-link"
|
||||
@tap="emit('toggle-replies', localComment)"
|
||||
>
|
||||
查看 {{ localComment.replyCount }} 条回复
|
||||
</text>
|
||||
<text v-else-if="localComment.repliesLoading" class="c-action-count">回复加载中...</text>
|
||||
<text v-else-if="localComment.replyCount" class="c-action-count">{{ localComment.replyCount }} 条回复</text>
|
||||
</view>
|
||||
|
||||
<view v-if="localComment.replies && localComment.replies.length" class="reply-panel">
|
||||
<view
|
||||
v-for="reply in localComment.replies"
|
||||
:key="reply.id"
|
||||
class="reply-item"
|
||||
:id="`reply-${reply.id}`"
|
||||
:class="{ 'reply-item--anchored': reply.id === props.activeAnchorId }"
|
||||
>
|
||||
<image class="reply-avatar" :src="reply.avatar" mode="aspectFill" />
|
||||
<view class="reply-main">
|
||||
<view class="reply-head">
|
||||
<view class="reply-user-wrap">
|
||||
<text class="reply-name">{{ reply.name }}</text>
|
||||
<ActionBadge v-if="reply.userAction" :action="reply.userAction" />
|
||||
</view>
|
||||
<view class="c-like reply-like" @tap="handleLike(reply)">
|
||||
<text class="like-icon reply-like-icon" :class="{ 'is-liked': reply.isLiked }"></text>
|
||||
<text class="like-count" :class="{ 'liked-text': reply.isLiked }">{{ reply.likes }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<text class="reply-time">{{ reply.time }}</text>
|
||||
<text class="reply-text">
|
||||
<text v-if="reply.replyToName" class="reply-at">@{{ reply.replyToName }} </text>{{ reply.content }}
|
||||
</text>
|
||||
|
||||
<view class="reply-footer">
|
||||
<text class="reply-btn" @tap="emitReply(reply)">回复</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="reply-panel-footer">
|
||||
<text
|
||||
v-if="localComment.repliesHasMore && !localComment.repliesLoading"
|
||||
class="reply-more-btn"
|
||||
@tap="emit('more-replies', localComment)"
|
||||
>
|
||||
查看更多回复
|
||||
</text>
|
||||
<text v-else-if="localComment.repliesLoading" class="reply-more-loading">加载更多中...</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="c-time">{{ comment.time }}</text>
|
||||
<text class="c-text">{{ comment.content }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import ActionBadge from "@/components/ActionBadge/ActionBadge.vue";
|
||||
import { topicItemCommentLike } from "@/api/topicItem";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
|
||||
const props = defineProps({
|
||||
comment: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default: () => ({})
|
||||
},
|
||||
likeHandler: {
|
||||
type: Function,
|
||||
default: null
|
||||
},
|
||||
pageName: {
|
||||
type: String,
|
||||
default: 'rating_detail'
|
||||
},
|
||||
activeAnchorId: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
});
|
||||
|
||||
const isLiked = ref(props.comment.isLiked || false);
|
||||
const currentLikes = ref(props.comment.likes || 0);
|
||||
let isLiking = false; // 防抖标志
|
||||
const emit = defineEmits(['need-login', 'like-change', 'reply', 'toggle-replies', 'more-replies']);
|
||||
const userStore = useUserStore();
|
||||
const isLoggedIn = computed(() => !!userStore.token || !!userStore.userInfo?.nickName);
|
||||
|
||||
watch(() => props.comment.likes, (newVal) => {
|
||||
currentLikes.value = newVal;
|
||||
const buildReplyItem = (reply = {}) => ({
|
||||
...reply,
|
||||
id: reply.id || '',
|
||||
name: reply.name || '匿名用户',
|
||||
avatar: reply.avatar || 'https://api.dicebear.com/7.x/avataaars/svg?seed=fallback',
|
||||
time: reply.time || '刚刚',
|
||||
content: reply.content || '',
|
||||
likes: Number(reply.likes || 0),
|
||||
isLiked: !!reply.isLiked,
|
||||
replyToName: reply.replyToName || '',
|
||||
parentCommentId: reply.parentCommentId || '',
|
||||
replyToCommentId: reply.replyToCommentId || '',
|
||||
userAction: reply.userAction || ''
|
||||
});
|
||||
|
||||
watch(() => props.comment.isLiked, (newVal) => {
|
||||
isLiked.value = newVal || false;
|
||||
const buildCommentState = (comment = {}) => ({
|
||||
...comment,
|
||||
likes: Number(comment.likes || 0),
|
||||
isLiked: !!comment.isLiked,
|
||||
choiceLabel: comment.choiceLabel || '',
|
||||
choiceTone: comment.choiceTone || '',
|
||||
replyCount: Number(comment.replyCount || 0),
|
||||
repliesLoading: !!comment.repliesLoading,
|
||||
repliesLoaded: !!comment.repliesLoaded,
|
||||
repliesHasMore: !!comment.repliesHasMore,
|
||||
repliesPage: Number(comment.repliesPage || 1),
|
||||
replies: Array.isArray(comment.replies) ? comment.replies.map(buildReplyItem) : []
|
||||
});
|
||||
|
||||
const handleLike = async () => {
|
||||
if (isLiking) return;
|
||||
isLiking = true;
|
||||
const localComment = ref(buildCommentState(props.comment));
|
||||
const likingMap = ref({});
|
||||
|
||||
// 乐观更新 UI
|
||||
isLiked.value = !isLiked.value;
|
||||
if (isLiked.value) {
|
||||
currentLikes.value += 1;
|
||||
watch(
|
||||
() => props.comment,
|
||||
(newVal) => {
|
||||
localComment.value = buildCommentState(newVal || {});
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
const emitReply = (target) => {
|
||||
emit('reply', {
|
||||
rootCommentId: localComment.value.id,
|
||||
parentCommentId: target.id === localComment.value.id ? localComment.value.id : (target.parentCommentId || localComment.value.id),
|
||||
replyToCommentId: target.id,
|
||||
replyToName: target.name,
|
||||
content: target.content
|
||||
});
|
||||
};
|
||||
|
||||
const patchLikeState = (targetId, updater) => {
|
||||
if (targetId === localComment.value.id) {
|
||||
localComment.value = {
|
||||
...localComment.value,
|
||||
...updater(localComment.value)
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
localComment.value = {
|
||||
...localComment.value,
|
||||
replies: localComment.value.replies.map((reply) => {
|
||||
if (reply.id !== targetId) return reply;
|
||||
return {
|
||||
...reply,
|
||||
...updater(reply)
|
||||
};
|
||||
})
|
||||
};
|
||||
};
|
||||
|
||||
const handleLike = async (target = null) => {
|
||||
if (!isLoggedIn.value) {
|
||||
emit('need-login');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTarget = target || localComment.value;
|
||||
if (!currentTarget?.id || likingMap.value[currentTarget.id]) return;
|
||||
|
||||
likingMap.value = {
|
||||
...likingMap.value,
|
||||
[currentTarget.id]: true
|
||||
};
|
||||
|
||||
const nextLiked = !currentTarget.isLiked;
|
||||
const nextLikes = nextLiked ? currentTarget.likes + 1 : Math.max(0, currentTarget.likes - 1);
|
||||
|
||||
patchLikeState(currentTarget.id, () => ({
|
||||
isLiked: nextLiked,
|
||||
likes: nextLikes
|
||||
}));
|
||||
|
||||
if (nextLiked) {
|
||||
uni.vibrateShort({ type: 'light' });
|
||||
} else {
|
||||
currentLikes.value -= 1;
|
||||
}
|
||||
|
||||
emit('like-change', {
|
||||
id: currentTarget.id,
|
||||
parentId: currentTarget.id === localComment.value.id ? '' : localComment.value.id,
|
||||
isReply: currentTarget.id !== localComment.value.id,
|
||||
isLiked: nextLiked,
|
||||
likes: nextLikes
|
||||
});
|
||||
|
||||
try {
|
||||
// 调用点赞接口 (点赞和取消点赞为同一个接口)
|
||||
await topicItemCommentLike({ commentId: props.comment.id });
|
||||
const likeRequest = props.likeHandler || topicItemCommentLike;
|
||||
await likeRequest({ commentId: currentTarget.id });
|
||||
uni.$trackRecord({
|
||||
eventName: 'like_comment',
|
||||
eventType: 'like',
|
||||
elementId: `comment_${currentTarget.id}`,
|
||||
elementContent: `评论项_${currentTarget.name}`,
|
||||
customParams: {
|
||||
comment: currentTarget.content,
|
||||
isReply: currentTarget.id !== localComment.value.id,
|
||||
page: props.pageName
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
// 接口调用失败,回滚 UI 状态
|
||||
isLiked.value = !isLiked.value;
|
||||
if (isLiked.value) {
|
||||
currentLikes.value += 1;
|
||||
} else {
|
||||
currentLikes.value -= 1;
|
||||
}
|
||||
patchLikeState(currentTarget.id, () => ({
|
||||
isLiked: currentTarget.isLiked,
|
||||
likes: currentTarget.likes
|
||||
}));
|
||||
emit('like-change', {
|
||||
id: currentTarget.id,
|
||||
parentId: currentTarget.id === localComment.value.id ? '' : localComment.value.id,
|
||||
isReply: currentTarget.id !== localComment.value.id,
|
||||
isLiked: currentTarget.isLiked,
|
||||
likes: currentTarget.likes
|
||||
});
|
||||
uni.showToast({ title: '操作失败', icon: 'none' });
|
||||
} finally {
|
||||
isLiking = false;
|
||||
const nextMap = { ...likingMap.value };
|
||||
delete nextMap[currentTarget.id];
|
||||
likingMap.value = nextMap;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -84,13 +266,21 @@ const handleLike = async () => {
|
||||
display: flex;
|
||||
padding: 32rpx 0;
|
||||
position: relative;
|
||||
transition: background-color 0.2s;
|
||||
transition: background-color 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.comment-item:active {
|
||||
background-color: #fafbfe;
|
||||
}
|
||||
|
||||
.comment-item--anchored {
|
||||
margin: 0 -16rpx;
|
||||
padding: 32rpx 16rpx;
|
||||
border-radius: 24rpx;
|
||||
background: linear-gradient(135deg, rgba(92, 67, 245, 0.1), rgba(92, 67, 245, 0.04));
|
||||
transform: translateY(-2rpx);
|
||||
}
|
||||
|
||||
.comment-item::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -149,6 +339,34 @@ const handleLike = async () => {
|
||||
.b-icon { font-size: 20rpx; margin-right: 6rpx; }
|
||||
.b-text { font-size: 20rpx; color: #666; font-weight: bold; }
|
||||
|
||||
.choice-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 42rpx;
|
||||
padding: 0 16rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(79, 70, 229, 0.08);
|
||||
margin-right: 12rpx;
|
||||
}
|
||||
|
||||
.choice-tag--left {
|
||||
background: rgba(92, 67, 245, 0.1);
|
||||
}
|
||||
|
||||
.choice-tag--right {
|
||||
background: rgba(255, 141, 95, 0.12);
|
||||
}
|
||||
|
||||
.choice-tag-text {
|
||||
font-size: 20rpx;
|
||||
font-weight: 700;
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
.choice-tag--right .choice-tag-text {
|
||||
color: #ff7a45;
|
||||
}
|
||||
|
||||
.c-like {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -194,4 +412,126 @@ const handleLike = async () => {
|
||||
line-height: 1.65;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
|
||||
.c-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.c-action-btn,
|
||||
.reply-btn,
|
||||
.c-action-count,
|
||||
.c-action-link {
|
||||
font-size: 22rpx;
|
||||
color: #8b8f98;
|
||||
}
|
||||
|
||||
.c-action-btn,
|
||||
.reply-btn,
|
||||
.c-action-link {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.reply-panel {
|
||||
margin-top: 20rpx;
|
||||
padding: 18rpx 18rpx 8rpx;
|
||||
border-radius: 24rpx;
|
||||
background: linear-gradient(180deg, #f6f7fb 0%, #f9fafc 100%);
|
||||
}
|
||||
|
||||
.reply-item {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
padding-bottom: 18rpx;
|
||||
border-radius: 20rpx;
|
||||
transition: background-color 0.2s, padding 0.2s;
|
||||
}
|
||||
|
||||
.reply-item--anchored {
|
||||
padding: 16rpx 14rpx 18rpx;
|
||||
background: rgba(92, 67, 245, 0.08);
|
||||
}
|
||||
|
||||
.reply-avatar {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: #eceff5;
|
||||
}
|
||||
|
||||
.reply-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.reply-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.reply-user-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.reply-name {
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.reply-time {
|
||||
display: block;
|
||||
margin: 6rpx 0 10rpx;
|
||||
font-size: 20rpx;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.reply-text {
|
||||
font-size: 24rpx;
|
||||
line-height: 1.7;
|
||||
color: #30343b;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.reply-at {
|
||||
color: #4f46e5;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.reply-footer {
|
||||
margin-top: 10rpx;
|
||||
}
|
||||
|
||||
.reply-like {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.reply-like-icon {
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
}
|
||||
|
||||
.reply-panel-footer {
|
||||
padding: 8rpx 0 10rpx 72rpx;
|
||||
}
|
||||
|
||||
.reply-more-btn,
|
||||
.reply-more-loading {
|
||||
font-size: 22rpx;
|
||||
color: #4f46e5;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.reply-more-loading {
|
||||
color: #9ca3af;
|
||||
}
|
||||
</style>
|
||||
|
||||
1104
components/CommentSection/CommentSection.vue
Normal file
@@ -94,6 +94,7 @@ import { getPlatformProvider, isSinglePageMode } from "@/utils/system";
|
||||
import { uploadImage } from "@/utils/common";
|
||||
import { apiLogin } from "@/api/auth.js";
|
||||
import { wxLogin, alipayLogin } from "@/utils/login.js";
|
||||
import { refreshMessageTabBarBadge } from "@/utils/tabBar";
|
||||
import PrivacyPopup from "@/components/PrivacyPopup/PrivacyPopup.vue";
|
||||
|
||||
const popupRef = ref(null);
|
||||
@@ -219,6 +220,7 @@ const handleAlipayLogin = async () => {
|
||||
|
||||
uni.hideLoading();
|
||||
uni.showToast({ title: "登录成功", icon: "success" });
|
||||
refreshMessageTabBarBadge();
|
||||
emit("logind");
|
||||
popupRef.value.close();
|
||||
|
||||
@@ -292,6 +294,7 @@ const confirmLogin = async () => {
|
||||
userStore.setToken(loginRes.token);
|
||||
uni.hideLoading();
|
||||
uni.showToast({ title: "登录成功", icon: "success" });
|
||||
refreshMessageTabBarBadge();
|
||||
emit("logind");
|
||||
popupRef.value.close();
|
||||
|
||||
|
||||
@@ -12,8 +12,9 @@
|
||||
<view class="sparkle s-2">✨</view>
|
||||
<view class="sparkle s-3">✨</view>
|
||||
</view>
|
||||
<text class="title">{{ type === 'comment' ? '评论成功' : '评分成功' }}</text>
|
||||
<text class="title">{{ type === 'comment' ? '评论成功' : type === 'release' ? '已提交审核' : '评分成功' }}</text>
|
||||
<text class="subtitle" v-if="type === 'comment'">{{ actionData?.label || '你的锐评已发布' }}</text>
|
||||
<text class="subtitle" v-else-if="type === 'release'">{{ actionData?.label || '请耐心等待管理员审核' }}</text>
|
||||
<text class="subtitle" v-else>你已将TA评价为「{{ actionData?.label || '...' }}」</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
<text class="topic-title">{{ topic.title }}</text>
|
||||
<view class="topic-stats">
|
||||
<view class="stat-item">
|
||||
<text class="stat-icon icon-group"></text>
|
||||
<image class="stat-icon stat-icon-img" src="/static/images/icon/group.png" mode="aspectFit"></image>
|
||||
<text>{{ topic.participantCount }}人参与</text>
|
||||
</view>
|
||||
<view class="stat-item heat">
|
||||
<text class="stat-icon icon-fire"></text>
|
||||
<image class="stat-icon stat-icon-img" src="/static/images/icon/fire.png" mode="aspectFit"></image>
|
||||
<text>热度 {{ topic.hotScore }} w</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -23,7 +23,13 @@
|
||||
:key="item.id"
|
||||
@tap="handleItemTap(item.id)"
|
||||
>
|
||||
<text class="rank-num" :class="'rank-' + (index + 1)">{{ index + 1 }}</text>
|
||||
<image
|
||||
v-if="index < 3"
|
||||
class="rank-icon"
|
||||
:src="`/static/images/icon/${index + 1}.png`"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text v-else class="rank-num" :class="'rank-' + (index + 1)">{{ index + 1 }}</text>
|
||||
<image class="item-avatar" :src="FILE_BASE_URL + item.avatarUrl" mode="aspectFill"></image>
|
||||
<text class="item-name">{{ item.name }}</text>
|
||||
<text class="item-score" :class="'score-' + (index + 1)">{{ item.scoreAvg }}</text>
|
||||
@@ -32,7 +38,7 @@
|
||||
|
||||
<!-- AI 总结 -->
|
||||
<view class="ai-summary" v-if="topic.aiSummary">
|
||||
<text class="ai-icon icon-robot"></text>
|
||||
<image class="ai-icon ai-icon-img" src="/static/images/icon/robot.png" mode="aspectFit"></image>
|
||||
<view class="summary-text">
|
||||
<text class="summary-label">AI总结:</text>
|
||||
<text class="summary-content">{{ topic.aiSummary }}</text>
|
||||
@@ -117,12 +123,8 @@ const handleItemTap = (itemId) => {
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.icon-group {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23999999'%3E%3Cpath d='M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.icon-fire {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23ff4d4f'%3E%3Cpath d='M19.48 13.03c-.02-.19-.13-.36-.29-.46-.17-.11-.38-.13-.56-.05-.98.42-2.12.3-2.92-.31-1.07-.81-1.55-2.22-1.28-3.75.12-.66.1-1.32-.06-1.97-.24-.95-1.1-1.6-2.09-1.58-1 .02-1.83.74-2 1.72-.25 1.55-.95 2.94-2 4.02-1.38 1.41-2.05 3.39-1.85 5.4.2 2.05 1.42 3.86 3.25 4.82 1.37.72 2.94 1.01 4.5.83 2.74-.32 5.06-2.28 5.86-4.95.23-.75.29-1.52.19-2.28-.06-.5-.21-.99-.41-1.45l-.34-.99zm-4.98 6.47c-1.37 0-2.5-1.13-2.5-2.5s1.13-2.5 2.5-2.5 2.5 1.13 2.5 2.5-1.13 2.5-2.5 2.5z'/%3E%3C/svg%3E");
|
||||
.stat-icon-img {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rank-list {
|
||||
@@ -152,6 +154,13 @@ const handleItemTap = (itemId) => {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.rank-icon {
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
margin-right: 16rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rank-1 {
|
||||
color: #2953ff;
|
||||
font-size: 40rpx;
|
||||
@@ -209,7 +218,10 @@ const handleItemTap = (itemId) => {
|
||||
margin-right: 12rpx;
|
||||
margin-top: 4rpx;
|
||||
flex-shrink: 0;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%237b46f1'%3E%3Cpath d='M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h5a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h5V5.73A2 2 0 0 1 10 4a2 2 0 0 1 2-2zm-3 8a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zm6 0a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.ai-icon-img {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.summary-text {
|
||||
@@ -244,4 +256,4 @@ const handleItemTap = (itemId) => {
|
||||
font-size: 22rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
"quickapp" : {},
|
||||
/* 小程序特有相关 */
|
||||
"mp-weixin" : {
|
||||
"appid" : "wx8d0a4bfb94c9a7f0",
|
||||
"appid" : "wx146a2e83b50f9f75",
|
||||
"__usePrivacyCheck__" : true,
|
||||
"setting" : {
|
||||
"urlCheck" : false
|
||||
|
||||
71
pages.json
@@ -3,7 +3,7 @@
|
||||
{
|
||||
"path": "pages/index/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "全民评分",
|
||||
"navigationBarTitleText": "夯拉评分",
|
||||
"enablePullDownRefresh": true,
|
||||
"navigationStyle": "custom",
|
||||
"backgroundColor": "#FFFFFF"
|
||||
@@ -49,6 +49,31 @@
|
||||
"backgroundColor": "#fbfbfb"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/pk/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "PK站",
|
||||
"navigationStyle": "custom",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/pk/detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "PK详情",
|
||||
"navigationStyle": "custom",
|
||||
"enablePullDownRefresh": true,
|
||||
"onReachBottomDistance": 50
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/message/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "消息",
|
||||
"enablePullDownRefresh": false,
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/webview/index",
|
||||
"style": {
|
||||
@@ -67,7 +92,7 @@
|
||||
{
|
||||
"path": "pages/rating/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "全民评分",
|
||||
"navigationBarTitleText": "夯拉评分",
|
||||
"navigationStyle": "custom",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
@@ -79,6 +104,32 @@
|
||||
"navigationStyle": "custom",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/rating/pk",
|
||||
"style": {
|
||||
"navigationBarTitleText": "PK 对决",
|
||||
"navigationStyle": "custom",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mine/topics",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我参与的话题",
|
||||
"navigationStyle": "custom",
|
||||
"enablePullDownRefresh": true,
|
||||
"onReachBottomDistance": 50
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mine/pk",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我参与的PK",
|
||||
"navigationStyle": "custom",
|
||||
"enablePullDownRefresh": true,
|
||||
"onReachBottomDistance": 50
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
@@ -103,11 +154,23 @@
|
||||
"iconPath": "static/images/tabBar/home.png",
|
||||
"selectedIconPath": "static/images/tabBar/home_s.png"
|
||||
},
|
||||
{
|
||||
"text": "PK站",
|
||||
"pagePath": "pages/pk/index",
|
||||
"iconPath": "static/images/tabBar/pk.png",
|
||||
"selectedIconPath": "static/images/tabBar/pk_s.png"
|
||||
},
|
||||
{
|
||||
"text": "发布",
|
||||
"pagePath": "pages/release/index",
|
||||
"iconPath": "static/images/tabBar/creation.png",
|
||||
"selectedIconPath": "static/images/tabBar/creation_s.png"
|
||||
"iconPath": "static/images/tabBar/publish.png",
|
||||
"selectedIconPath": "static/images/tabBar/publish_s.png"
|
||||
},
|
||||
{
|
||||
"text": "消息",
|
||||
"pagePath": "pages/message/index",
|
||||
"iconPath": "static/images/tabBar/message.png",
|
||||
"selectedIconPath": "static/images/tabBar/message_s.png"
|
||||
},
|
||||
{
|
||||
"text": "我的",
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
@tap="handleLogin"
|
||||
/>
|
||||
<view class="header-texts">
|
||||
<text class="title">全民评分</text>
|
||||
<text class="title">夯拉评分</text>
|
||||
<text class="subtitle">看看全国网友怎么评分</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -120,8 +120,9 @@ const loadTopicList = async (categoryId, page = 1) => {
|
||||
try {
|
||||
loading.value = true;
|
||||
const res = await fetchTopicList(categoryId, page);
|
||||
// 提取真实数据数组
|
||||
const list = res?.list || [];
|
||||
const rawList =res?.list || [];
|
||||
|
||||
const list = Array.isArray(rawList) ? rawList : [];
|
||||
// 合并数据
|
||||
topicList.value = [...topicList.value, ...list];
|
||||
hasMore.value = list.length >= 5;
|
||||
@@ -136,58 +137,31 @@ const loadTopicList = async (categoryId, page = 1) => {
|
||||
const switchCategory = (categoryId) => {
|
||||
if (currentCategoryId.value === categoryId) return;
|
||||
currentCategoryId.value = categoryId;
|
||||
|
||||
// 切换分类时重新加载数据
|
||||
uni.$trackRecord({
|
||||
eventName: 'switch_category',
|
||||
eventType: 'click',
|
||||
elementId: `category_${categoryId}`,
|
||||
elementContent: categories.value.find(c => c.id === categoryId)?.title || '未知分类',
|
||||
});
|
||||
// 切换分类时重新加载数据
|
||||
currentPage.value = 1;
|
||||
hasMore.value = true;
|
||||
loadTopicList(categoryId, currentPage.value);
|
||||
};
|
||||
|
||||
const mockData = [
|
||||
{
|
||||
id: 1,
|
||||
title: '你心中的千古一帝',
|
||||
participants: '23.5万',
|
||||
heat: '98k',
|
||||
items: [
|
||||
{ id: 1, name: '李世民', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=1', score: '9.8' },
|
||||
{ id: 2, name: '秦始皇', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=2', score: '9.5' },
|
||||
{ id: 3, name: '康熙', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=3', score: '9.2' },
|
||||
],
|
||||
aiSummary: '多数用户认为李世民综合能力最强,兼具文治武功。',
|
||||
tags: ['历史', '皇帝', '争议']
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '国产手机系统谁最强',
|
||||
participants: '15万',
|
||||
heat: '76k',
|
||||
items: [
|
||||
{ id: 1, name: 'iOS', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=4', score: '8.5' },
|
||||
{ id: 2, name: 'HyperOS', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=5', score: '8.2' },
|
||||
{ id: 3, name: 'OriginOS', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=6', score: '8.0' },
|
||||
],
|
||||
aiSummary: '系统流畅度与动效是用户关注的核心,底层优化成为评分关键。',
|
||||
tags: ['数码', '手机', '测评']
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '最值得定居的城市',
|
||||
participants: '12.8万',
|
||||
heat: '62k',
|
||||
items: [
|
||||
{ id: 1, name: '成都', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=7', score: '9.2' },
|
||||
{ id: 2, name: '杭州', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=8', score: '8.9' },
|
||||
{ id: 3, name: '厦门', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=9', score: '8.7' },
|
||||
],
|
||||
aiSummary: '宜居性与就业机会是主要博弈点,慢节奏生活深受青睐。',
|
||||
tags: ['城市', '生活', '推荐']
|
||||
}
|
||||
];
|
||||
const topicList = ref([]);
|
||||
|
||||
const topicList = ref([...mockData]);
|
||||
const getCurrentCategoryTitle = () => {
|
||||
return categories.value.find((category) => category.id === currentCategoryId.value)?.title || "推荐";
|
||||
};
|
||||
|
||||
const goToRating = (topicId, itemId) => {
|
||||
uni.$trackRecord({
|
||||
eventName: 'jumpto_rating',
|
||||
eventType: 'jump',
|
||||
elementId: `topic_${topicId}`,
|
||||
elementContent: `主题${topicId}`,
|
||||
});
|
||||
uni.navigateTo({
|
||||
url: `/pages/rating/index?topicId=${topicId}&itemId=${itemId}`
|
||||
});
|
||||
@@ -226,7 +200,11 @@ onShow(() => {
|
||||
initData();
|
||||
});
|
||||
|
||||
const handleLoginSuccess = () => {
|
||||
const handleLoginSuccess = async () => {
|
||||
currentPage.value = 1;
|
||||
hasMore.value = true;
|
||||
await loadCategories();
|
||||
await loadTopicList(currentCategoryId.value, currentPage.value);
|
||||
};
|
||||
|
||||
// 下拉刷新
|
||||
@@ -247,8 +225,9 @@ onReachBottom(() => {
|
||||
onShareAppMessage(async () => {
|
||||
const shareToken = await getShareToken("index");
|
||||
getShareReward();
|
||||
const categoryTitle = getCurrentCategoryTitle();
|
||||
return {
|
||||
title: "全民评分",
|
||||
title: `夯拉评分 | ${categoryTitle}榜单正在热议`,
|
||||
path: "/pages/index/index?shareToken=" + shareToken,
|
||||
};
|
||||
});
|
||||
@@ -256,8 +235,9 @@ onShareAppMessage(async () => {
|
||||
onShareTimeline(async () => {
|
||||
const shareToken = await getShareToken("index");
|
||||
getShareReward();
|
||||
const categoryTitle = getCurrentCategoryTitle();
|
||||
return {
|
||||
title: "全民评分",
|
||||
title: `${categoryTitle}圈都在玩夯拉评分,来看看谁最夯`,
|
||||
query: `shareToken=${shareToken}`,
|
||||
};
|
||||
});
|
||||
|
||||
886
pages/message/index.vue
Normal file
@@ -0,0 +1,886 @@
|
||||
<template>
|
||||
<view class="message-page">
|
||||
<view class="nav-bar" :style="{ paddingTop: statusBarHeight + 'px' }">
|
||||
<view class="nav-content">
|
||||
<view>
|
||||
<text class="nav-title">系统消息</text>
|
||||
<text class="nav-subtitle">审核通知、评论互动和系统提醒都会在这里出现</text>
|
||||
</view>
|
||||
<view class="nav-right">
|
||||
<view v-if="unreadCount > 0" class="unread-chip">
|
||||
<text class="unread-chip-text">{{ unreadCount }} 未读</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view :style="{ height: statusBarHeight + 64 + 'px' }"></view>
|
||||
|
||||
<view class="page-body">
|
||||
<view v-if="!isLoggedIn" class="state-card login-card">
|
||||
<text class="state-title">登录后查看你的系统消息</text>
|
||||
<text class="state-desc">审核结果、评论互动、点赞提醒都会同步到这里。</text>
|
||||
<button class="primary-btn" @tap="openLoginPopup('请先登录后查看消息')">立即登录</button>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<view class="filter-bar">
|
||||
<view class="filter-tabs">
|
||||
<view
|
||||
v-for="tab in filterTabs"
|
||||
:key="tab.value"
|
||||
class="filter-tab"
|
||||
:class="{ active: activeFilter === tab.value }"
|
||||
@tap="activeFilter = tab.value"
|
||||
>
|
||||
<text class="filter-tab-text">{{ tab.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view
|
||||
class="read-all-btn filter-read-all-btn"
|
||||
:class="{ disabled: readingAll || unreadCount === 0 }"
|
||||
@tap="handleMarkAllRead"
|
||||
>
|
||||
<text class="read-all-text">{{ readingAll ? "处理中" : "全部已读" }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loading && !notificationList.length" class="state-card">
|
||||
<text class="state-title">消息加载中...</text>
|
||||
<text class="state-desc">正在同步你的最新通知</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="loadError && !notificationList.length" class="state-card">
|
||||
<text class="state-title">消息加载失败</text>
|
||||
<text class="state-desc">{{ loadError }}</text>
|
||||
<button class="primary-btn" @tap="refreshList">重新加载</button>
|
||||
</view>
|
||||
|
||||
<view v-else-if="groupedNotifications.length" class="section-list">
|
||||
<view v-for="section in groupedNotifications" :key="section.day" class="section-block">
|
||||
<view class="section-header">
|
||||
<text class="section-title">{{ formatDayLabel(section.day) }}</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="item in section.items"
|
||||
:key="item.id"
|
||||
class="message-card"
|
||||
:class="{ unread: !item.isRead }"
|
||||
@tap="handleItemTap(item)"
|
||||
>
|
||||
<view class="message-avatar-wrap">
|
||||
<image class="message-avatar" :src="resolveAvatar(item)" mode="aspectFill" />
|
||||
<view v-if="!item.isRead" class="message-dot"></view>
|
||||
</view>
|
||||
|
||||
<view class="message-main">
|
||||
<view class="message-head">
|
||||
<view class="message-head-left">
|
||||
<text class="message-title">{{ item.title || typeLabelMap[item.type] || "系统通知" }}</text>
|
||||
<view
|
||||
class="type-tag"
|
||||
:style="{
|
||||
color: resolveTypeStyle(item.type).color,
|
||||
background: resolveTypeStyle(item.type).background
|
||||
}"
|
||||
>
|
||||
<text class="type-tag-text">{{ typeLabelMap[item.type] || "提醒" }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="message-time">{{ formatRelativeTime(item.createdAt) }}</text>
|
||||
</view>
|
||||
|
||||
<text class="message-content">{{ item.content || "你有一条新的系统消息" }}</text>
|
||||
|
||||
<view class="message-footer">
|
||||
<text class="message-meta">{{ resolveActionText(item) }}</text>
|
||||
<text class="message-link">{{ resolveJumpText(item) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="list-status">
|
||||
<text>{{ loading ? "加载中..." : hasMore ? "上拉加载更多" : "没有更多消息了" }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="state-card">
|
||||
<text class="state-title">{{ activeFilter === "unread" ? "暂时没有未读消息" : "还没有系统消息" }}</text>
|
||||
<text class="state-desc">{{ activeFilter === "unread" ? "已帮你处理完所有提醒" : "新的审核、评论和互动提醒会显示在这里" }}</text>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
|
||||
<LoginPopup ref="loginPopupRef" @logind="handleLoginSuccess" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onPullDownRefresh, onReachBottom, onShow } from "@dcloudio/uni-app";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
import { getStatusBarHeight } from "@/utils/system";
|
||||
import { FILE_BASE_URL } from "@/utils/constants";
|
||||
import { syncMessageTabBarBadge } from "@/utils/tabBar";
|
||||
import {
|
||||
fetchNotificationList,
|
||||
fetchUnreadCount,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
} from "@/api/notification";
|
||||
import LoginPopup from "@/components/LoginPopup/LoginPopup.vue";
|
||||
|
||||
const DEFAULT_AVATAR = "/static/images/icon/robot.png";
|
||||
const APPROVED_AVATAR = "/static/images/icon/approved.png";
|
||||
const REJECTED_AVATAR = "/static/images/icon/rejected.png";
|
||||
const PENDING_AVATAR = "/static/images/icon/pending.png";
|
||||
|
||||
const userStore = useUserStore();
|
||||
const loginPopupRef = ref(null);
|
||||
const statusBarHeight = ref(getStatusBarHeight() || 20);
|
||||
const loading = ref(false);
|
||||
const readingAll = ref(false);
|
||||
const page = ref(1);
|
||||
const hasMore = ref(true);
|
||||
const totalCount = ref(0);
|
||||
const unreadCount = ref(0);
|
||||
const loadError = ref("");
|
||||
const activeFilter = ref("all");
|
||||
const notificationList = ref([]);
|
||||
const readingIds = ref([]);
|
||||
|
||||
const filterTabs = [
|
||||
{ label: "全部", value: "all" },
|
||||
{ label: "未读", value: "unread" },
|
||||
];
|
||||
|
||||
const typeLabelMap = {
|
||||
topic_submit: "话题提交",
|
||||
topic_approved: "审核通过",
|
||||
topic_rejected: "审核未过",
|
||||
item_submit: "评分项提交",
|
||||
item_approved: "评分项通过",
|
||||
item_rejected: "评分项未过",
|
||||
comment: "评论互动",
|
||||
comment_like: "评论获赞",
|
||||
system: "系统通知",
|
||||
};
|
||||
|
||||
const typeStyleMap = {
|
||||
topic_submit: { color: "#ff7d00", background: "rgba(255, 125, 0, 0.12)" },
|
||||
topic_approved: { color: "#12b76a", background: "rgba(18, 183, 106, 0.12)" },
|
||||
topic_rejected: { color: "#f04438", background: "rgba(240, 68, 56, 0.12)" },
|
||||
item_submit: { color: "#7a5af8", background: "rgba(122, 90, 248, 0.12)" },
|
||||
item_approved: { color: "#0ba5ec", background: "rgba(11, 165, 236, 0.12)" },
|
||||
item_rejected: { color: "#ef6820", background: "rgba(239, 104, 32, 0.12)" },
|
||||
comment: { color: "#ec4899", background: "rgba(236, 72, 153, 0.12)" },
|
||||
comment_like: { color: "#f63d68", background: "rgba(246, 61, 104, 0.12)" },
|
||||
system: { color: "#667085", background: "rgba(102, 112, 133, 0.12)" },
|
||||
};
|
||||
|
||||
const isLoggedIn = computed(() => !!userStore.token || !!userStore.userInfo?.nickName);
|
||||
let firstShow = true;
|
||||
|
||||
const todayCount = computed(() => {
|
||||
const today = formatDateKey(new Date());
|
||||
return notificationList.value.filter((item) => (item.day || formatDateKey(item.createdAt)) === today).length;
|
||||
});
|
||||
|
||||
const filteredNotifications = computed(() => {
|
||||
if (activeFilter.value === "unread") {
|
||||
return notificationList.value.filter((item) => !item.isRead);
|
||||
}
|
||||
return notificationList.value;
|
||||
});
|
||||
|
||||
const groupedNotifications = computed(() => {
|
||||
const map = {};
|
||||
filteredNotifications.value.forEach((item) => {
|
||||
const day = item.day || formatDateKey(item.createdAt);
|
||||
if (!map[day]) {
|
||||
map[day] = [];
|
||||
}
|
||||
map[day].push(item);
|
||||
});
|
||||
|
||||
return Object.keys(map)
|
||||
.sort((a, b) => new Date(b).getTime() - new Date(a).getTime())
|
||||
.map((day) => ({
|
||||
day,
|
||||
items: map[day].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
|
||||
}));
|
||||
});
|
||||
|
||||
const openLoginPopup = (message = "请先登录后再操作") => {
|
||||
loginPopupRef.value?.open();
|
||||
if (message) {
|
||||
uni.showToast({ title: message, icon: "none" });
|
||||
}
|
||||
};
|
||||
|
||||
const formatDateKey = (value) => {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
const year = date.getFullYear();
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, "0");
|
||||
const day = `${date.getDate()}`.padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const formatRelativeTime = (value) => {
|
||||
if (!value) return "刚刚";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "刚刚";
|
||||
|
||||
const diff = Date.now() - date.getTime();
|
||||
const minute = 60 * 1000;
|
||||
const hour = 60 * minute;
|
||||
|
||||
if (diff < minute) return "刚刚";
|
||||
if (diff < hour) return `${Math.max(1, Math.floor(diff / minute))}分钟前`;
|
||||
if (diff < 24 * hour) return `${Math.max(1, Math.floor(diff / hour))}小时前`;
|
||||
|
||||
const month = date.getMonth() + 1;
|
||||
const day = date.getDate();
|
||||
const hours = `${date.getHours()}`.padStart(2, "0");
|
||||
const minutes = `${date.getMinutes()}`.padStart(2, "0");
|
||||
const currentYear = new Date().getFullYear();
|
||||
if (date.getFullYear() === currentYear) {
|
||||
return `${month}月${day}日 ${hours}:${minutes}`;
|
||||
}
|
||||
return `${date.getFullYear()}-${`${month}`.padStart(2, "0")}-${`${day}`.padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const formatDayLabel = (day) => {
|
||||
if (!day) return "更早";
|
||||
const today = formatDateKey(new Date());
|
||||
const yesterdayDate = new Date();
|
||||
yesterdayDate.setDate(yesterdayDate.getDate() - 1);
|
||||
const yesterday = formatDateKey(yesterdayDate);
|
||||
|
||||
if (day === today) return "今天";
|
||||
if (day === yesterday) return "昨天";
|
||||
|
||||
const date = new Date(day);
|
||||
if (Number.isNaN(date.getTime())) return day;
|
||||
return `${date.getMonth() + 1}月${date.getDate()}日`;
|
||||
};
|
||||
|
||||
const resolveAvatar = (item) => {
|
||||
const avatar = item?.fromUser?.avatar;
|
||||
if (!avatar) {
|
||||
if(item.title.includes("已通过")) {
|
||||
return APPROVED_AVATAR;
|
||||
} else if(item.title.includes("未通过")) {
|
||||
return REJECTED_AVATAR;
|
||||
} else if(item.title.includes("已提交审核")) {
|
||||
return PENDING_AVATAR;
|
||||
} else {
|
||||
return DEFAULT_AVATAR;
|
||||
}
|
||||
}
|
||||
return String(avatar).startsWith("http") ? avatar : `${FILE_BASE_URL}${avatar}`;
|
||||
};
|
||||
|
||||
const resolveTypeStyle = (type) => {
|
||||
return typeStyleMap[type] || typeStyleMap.system;
|
||||
};
|
||||
|
||||
const resolveActionText = (item) => {
|
||||
if (!item.isRead) return "点击后将标记为已读";
|
||||
if (item.updatedAt && item.updatedAt !== item.createdAt) return "已处理";
|
||||
return "查看详情";
|
||||
};
|
||||
|
||||
const resolveJumpText = (item) => {
|
||||
if(item.title.includes("已提交审核") || item.title.includes("未通过") || item.title.includes("评分项")) return "";
|
||||
|
||||
if (item.topicId) return "查看话题";
|
||||
return "查看消息";
|
||||
};
|
||||
|
||||
const normalizeListResponse = (res) => {
|
||||
const source = res?.data || res || {};
|
||||
const list = Array.isArray(source.list)
|
||||
? source.list
|
||||
: Array.isArray(res?.list)
|
||||
? res.list
|
||||
: [];
|
||||
const total = Number(source.totalCount ?? res?.totalCount ?? list.length);
|
||||
return { list, total };
|
||||
};
|
||||
|
||||
const mergeNotificationList = (list, reset = false) => {
|
||||
if (reset) {
|
||||
notificationList.value = list;
|
||||
return;
|
||||
}
|
||||
|
||||
const map = new Map(notificationList.value.map((item) => [item.id, item]));
|
||||
list.forEach((item) => {
|
||||
map.set(item.id, item);
|
||||
});
|
||||
notificationList.value = Array.from(map.values()).sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
};
|
||||
|
||||
const loadUnread = async () => {
|
||||
if (!isLoggedIn.value) {
|
||||
unreadCount.value = 0;
|
||||
syncMessageTabBarBadge(0);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetchUnreadCount();
|
||||
const count =
|
||||
Number(
|
||||
res?.data?.count ??
|
||||
res?.data?.unreadCount ??
|
||||
res?.count ??
|
||||
res?.unreadCount ??
|
||||
res?.data ??
|
||||
0
|
||||
) || 0;
|
||||
unreadCount.value = count;
|
||||
syncMessageTabBarBadge(count);
|
||||
} catch (error) {
|
||||
unreadCount.value = unreadCount.value || 0;
|
||||
syncMessageTabBarBadge(unreadCount.value);
|
||||
}
|
||||
};
|
||||
|
||||
const loadNotifications = async ({ reset = false } = {}) => {
|
||||
if (!isLoggedIn.value) {
|
||||
notificationList.value = [];
|
||||
totalCount.value = 0;
|
||||
hasMore.value = false;
|
||||
loadError.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (loading.value) return;
|
||||
if (!reset && !hasMore.value) return;
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
const nextPage = reset ? 1 : page.value;
|
||||
const res = await fetchNotificationList({ page: nextPage });
|
||||
const { list, total } = normalizeListResponse(res);
|
||||
totalCount.value = total;
|
||||
mergeNotificationList(list, reset);
|
||||
hasMore.value = notificationList.value.length < total && list.length > 0;
|
||||
page.value = nextPage + 1;
|
||||
} catch (error) {
|
||||
console.error("获取消息列表失败:", error);
|
||||
loadError.value = "网络开了点小差,稍后再试";
|
||||
if (reset) {
|
||||
notificationList.value = [];
|
||||
totalCount.value = 0;
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
uni.stopPullDownRefresh();
|
||||
}
|
||||
};
|
||||
|
||||
const refreshList = async () => {
|
||||
page.value = 1;
|
||||
hasMore.value = true;
|
||||
await Promise.all([loadUnread(), loadNotifications({ reset: true })]);
|
||||
};
|
||||
|
||||
const setItemReadState = (id, isRead = true) => {
|
||||
notificationList.value = notificationList.value.map((item) => {
|
||||
if (item.id !== id) return item;
|
||||
return {
|
||||
...item,
|
||||
isRead,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleNavigate = (item) => {
|
||||
const isCommentInteraction = item?.type === 'comment' || item?.type === 'comment_like';
|
||||
const targetCommentId =
|
||||
item?.commentId ||
|
||||
(item?.targetType === 'comment' ? item?.targetId : '') ||
|
||||
item?.metadata?.commentId ||
|
||||
'';
|
||||
|
||||
if (item.itemId) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/rating/detail?topicId=${item.topicId || ""}&itemId=${item.itemId}${isCommentInteraction && targetCommentId ? `&commentId=${targetCommentId}` : ""}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.topicId) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/rating/index?topicId=${item.topicId}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const markSingleAsRead = async (item) => {
|
||||
if (!item?.id || item.isRead || readingIds.value.includes(item.id)) return;
|
||||
|
||||
readingIds.value = [...readingIds.value, item.id];
|
||||
try {
|
||||
await markAsRead(item.id);
|
||||
setItemReadState(item.id, true);
|
||||
unreadCount.value = Math.max(0, unreadCount.value - 1);
|
||||
syncMessageTabBarBadge(unreadCount.value);
|
||||
} catch (error) {
|
||||
console.error("标记消息已读失败:", error);
|
||||
} finally {
|
||||
readingIds.value = readingIds.value.filter((id) => id !== item.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemTap = async (item) => {
|
||||
await markSingleAsRead(item);
|
||||
if(item.title.includes("未通过") || item.title.includes("已提交审核") || item.title.includes("评分项")) return
|
||||
handleNavigate(item);
|
||||
};
|
||||
|
||||
const handleMarkAllRead = async () => {
|
||||
if (!isLoggedIn.value) {
|
||||
openLoginPopup("请先登录后再操作");
|
||||
return;
|
||||
}
|
||||
if (readingAll.value || unreadCount.value === 0) return;
|
||||
|
||||
try {
|
||||
readingAll.value = true;
|
||||
await markAllAsRead();
|
||||
notificationList.value = notificationList.value.map((item) => ({
|
||||
...item,
|
||||
isRead: true,
|
||||
}));
|
||||
unreadCount.value = 0;
|
||||
syncMessageTabBarBadge(0);
|
||||
uni.showToast({ title: "已全部标记为已读", icon: "none" });
|
||||
} catch (error) {
|
||||
console.error("全部标记已读失败:", error);
|
||||
uni.showToast({ title: "操作失败,请稍后再试", icon: "none" });
|
||||
} finally {
|
||||
readingAll.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoginSuccess = async () => {
|
||||
await refreshList();
|
||||
};
|
||||
|
||||
onLoad(() => {
|
||||
refreshList();
|
||||
});
|
||||
|
||||
onShow(() => {
|
||||
if (firstShow) {
|
||||
firstShow = false;
|
||||
return;
|
||||
}
|
||||
refreshList();
|
||||
});
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
refreshList();
|
||||
});
|
||||
|
||||
onReachBottom(() => {
|
||||
loadNotifications();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.message-page {
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(255, 116, 71, 0.1) 0, rgba(255, 116, 71, 0) 36%),
|
||||
#f7f7f8;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nav-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
background: rgba(247, 247, 248, 0.9);
|
||||
backdrop-filter: blur(18rpx);
|
||||
}
|
||||
|
||||
.nav-content {
|
||||
min-height: 64px;
|
||||
padding: 0 28rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
display: block;
|
||||
font-size: 38rpx;
|
||||
font-weight: 700;
|
||||
color: #18181b;
|
||||
}
|
||||
|
||||
.nav-subtitle {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: #8a8f99;
|
||||
}
|
||||
|
||||
.nav-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.unread-chip {
|
||||
padding: 10rpx 18rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(255, 59, 48, 0.1);
|
||||
}
|
||||
|
||||
.unread-chip-text {
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.read-all-btn {
|
||||
padding: 12rpx 20rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #111111;
|
||||
}
|
||||
|
||||
.filter-read-all-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 10rpx 22rpx;
|
||||
}
|
||||
|
||||
.read-all-btn.disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.read-all-text {
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.page-body {
|
||||
padding: 20rpx 24rpx 140rpx;
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
padding: 30rpx 28rpx;
|
||||
border-radius: 32rpx;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #fff5f2 100%);
|
||||
box-shadow: 0 14rpx 34rpx rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.hero-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
display: block;
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
color: #171717;
|
||||
}
|
||||
|
||||
.hero-desc {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.6;
|
||||
color: #7a7f88;
|
||||
}
|
||||
|
||||
.hero-icon {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
margin-top: 28rpx;
|
||||
padding-top: 24rpx;
|
||||
border-top: 1rpx solid rgba(17, 17, 17, 0.06);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hero-stat {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.hero-stat-num {
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
color: #111111;
|
||||
}
|
||||
|
||||
.hero-stat-num.accent {
|
||||
color: #ff5a36;
|
||||
}
|
||||
|
||||
.hero-stat-label {
|
||||
font-size: 22rpx;
|
||||
color: #8a8f99;
|
||||
}
|
||||
|
||||
.hero-divider {
|
||||
width: 1rpx;
|
||||
height: 56rpx;
|
||||
background: rgba(17, 17, 17, 0.08);
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
margin-top: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
padding: 8rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #ffffff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
box-shadow: 0 10rpx 24rpx rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.filter-tab {
|
||||
min-width: 112rpx;
|
||||
height: 60rpx;
|
||||
padding: 0 24rpx;
|
||||
border-radius: 999rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.filter-tab.active {
|
||||
background: #111111;
|
||||
}
|
||||
|
||||
.filter-tab-text {
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
color: #8a8f99;
|
||||
}
|
||||
|
||||
.filter-tab.active .filter-tab-text {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.filter-hint {
|
||||
font-size: 22rpx;
|
||||
color: #9aa0aa;
|
||||
}
|
||||
|
||||
.section-list {
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.section-block + .section-block {
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 6rpx 8rpx 14rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
color: #8a8f99;
|
||||
}
|
||||
|
||||
.message-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
padding: 24rpx;
|
||||
border-radius: 28rpx;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 12rpx 28rpx rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.message-card + .message-card {
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.message-card.unread {
|
||||
background: linear-gradient(180deg, #ffffff 0%, #fff9f7 100%);
|
||||
}
|
||||
|
||||
.message-avatar-wrap {
|
||||
position: relative;
|
||||
width: 84rpx;
|
||||
height: 84rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
width: 84rpx;
|
||||
height: 84rpx;
|
||||
border-radius: 50%;
|
||||
background: #f4f5f7;
|
||||
}
|
||||
|
||||
.message-dot {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 2rpx;
|
||||
width: 18rpx;
|
||||
height: 18rpx;
|
||||
border-radius: 50%;
|
||||
background: #ff4d4f;
|
||||
border: 4rpx solid #ffffff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.message-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.message-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.message-head-left {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.message-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: #18181b;
|
||||
}
|
||||
|
||||
.type-tag {
|
||||
padding: 6rpx 14rpx;
|
||||
border-radius: 999rpx;
|
||||
}
|
||||
|
||||
.type-tag-text {
|
||||
font-size: 20rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
flex-shrink: 0;
|
||||
font-size: 22rpx;
|
||||
color: #a0a4ae;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
font-size: 26rpx;
|
||||
line-height: 1.7;
|
||||
color: #555b66;
|
||||
}
|
||||
|
||||
.message-footer {
|
||||
margin-top: 16rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
font-size: 22rpx;
|
||||
color: #a0a4ae;
|
||||
}
|
||||
|
||||
.message-link {
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
color: #ff5a36;
|
||||
}
|
||||
|
||||
.state-card {
|
||||
margin-top: 24rpx;
|
||||
padding: 44rpx 32rpx;
|
||||
border-radius: 32rpx;
|
||||
background: #ffffff;
|
||||
text-align: center;
|
||||
box-shadow: 0 12rpx 28rpx rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
|
||||
.state-title {
|
||||
display: block;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #18181b;
|
||||
}
|
||||
|
||||
.state-desc {
|
||||
display: block;
|
||||
margin-top: 14rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.7;
|
||||
color: #8a8f99;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
margin-top: 24rpx;
|
||||
height: 84rpx;
|
||||
line-height: 84rpx;
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(135deg, #ff7a45 0%, #ff4d4f 100%);
|
||||
color: #ffffff;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.primary-btn::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.list-status {
|
||||
padding: 28rpx 0 12rpx;
|
||||
text-align: center;
|
||||
font-size: 22rpx;
|
||||
color: #a0a4ae;
|
||||
}
|
||||
</style>
|
||||
@@ -5,7 +5,7 @@
|
||||
class="nav-bar"
|
||||
:style="{ paddingTop: navBarTop + 'px', height: navBarHeight + 'px' }"
|
||||
>
|
||||
<!-- <text class="nav-title">我的</text> -->
|
||||
<!-- Optional title -->
|
||||
</view>
|
||||
|
||||
<scroll-view
|
||||
@@ -14,85 +14,87 @@
|
||||
:style="{ paddingTop: navBarTop + navBarHeight + 'px' }"
|
||||
>
|
||||
<view class="content-wrap">
|
||||
|
||||
<!-- User Card -->
|
||||
<view class="user-card" @tap="handleUserClick">
|
||||
<view class="avatar-box">
|
||||
<image :src="userInfo.avatarUrl" class="avatar" mode="aspectFill" />
|
||||
<!-- <view class="red-badge"><text class="fire">🔥</text></view> -->
|
||||
</view>
|
||||
<view class="user-info">
|
||||
<view class="row-1">
|
||||
<text class="nickname">{{ userInfo.nickName }}</text>
|
||||
<view class="vip-tag" v-if="isLoggedIn && userInfo.isVip">
|
||||
<text class="vip-text">VIP 祥瑞会员</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="row-2" v-if="isLoggedIn">
|
||||
<!-- <text class="arrow-icon">➤</text> -->
|
||||
<!-- <text class="stats-text"
|
||||
>已发送 <text class="num">3</text> 条新春祝福</text
|
||||
> -->
|
||||
</view>
|
||||
<view class="row-2" v-else>
|
||||
<text class="stats-text">点击登录解锁更多功能</text>
|
||||
<view class="user-card">
|
||||
<view class="avatar-container" @tap="handleUserClick">
|
||||
<view class="avatar-ring">
|
||||
<image :src="userInfo.avatarUrl" class="avatar" mode="aspectFill" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="card-arrow">›</view>
|
||||
</view>
|
||||
|
||||
<text class="nickname" @tap="handleUserClick">{{ userInfo.nickName }}</text>
|
||||
|
||||
<view class="tags-container" v-if="isLoggedIn && userStats.label && userStats.label.length">
|
||||
<!-- <text class="sparkle-icon">✨</text>
|
||||
<text class="tags-text">{{ userStats.label.join(' | ') }}</text> -->
|
||||
</view>
|
||||
<view class="tags-container" v-else-if="!isLoggedIn">
|
||||
<text class="tags-text">点击登录解锁更多功能</text>
|
||||
</view>
|
||||
|
||||
<!-- VIP Banner -->
|
||||
<view v-if="!isIos" class="vip-banner" @tap="navTo('vip')">
|
||||
<view class="vip-left">
|
||||
<view class="vip-icon-box">
|
||||
<uni-icons type="vip-filled" size="32" color="#5d4037" />
|
||||
<view v-if="isLoggedIn && userStats.joinedDay > 0" class="joined-day-pill">
|
||||
<text class="joined-day-label">已加入</text>
|
||||
<text class="joined-day-value">{{ userStats.joinedDay }}</text>
|
||||
<text class="joined-day-label">天</text>
|
||||
</view>
|
||||
|
||||
<view class="stats-container">
|
||||
<view class="stat-item">
|
||||
<text class="stat-num">{{ isLoggedIn ? userStats.participant : '-' }}</text>
|
||||
<text class="stat-label">参与话题</text>
|
||||
</view>
|
||||
<view class="vip-content">
|
||||
<view class="vip-title-row">
|
||||
<text class="vip-title">祥瑞会员中心</text>
|
||||
<view class="diamond-tag">DIAMOND</view>
|
||||
</view>
|
||||
<text class="vip-subtitle">开通会员解锁全部特权</text>
|
||||
<view class="stat-divider"></view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-num">{{ isLoggedIn ? userStats.pkCount : '-' }}</text>
|
||||
<text class="stat-label">参与PK</text>
|
||||
</view>
|
||||
<view class="stat-divider"></view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-num">{{ isLoggedIn ? userStats.liked : '-' }}</text>
|
||||
<text class="stat-label">获赞</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="vip-right">
|
||||
<text class="open-text">立即开通</text>
|
||||
<text class="arrow">›</text>
|
||||
|
||||
<view class="action-buttons">
|
||||
<button class="btn-primary" @tap="navTo('profile')">编辑资料</button>
|
||||
<button v-if="isLoggedIn" class="btn-secondary" open-type="share">
|
||||
<text class="share-icon-btn"></text> 分享主页
|
||||
</button>
|
||||
<button v-else class="btn-secondary" @tap="openLoginPopup('请先登录后再分享主页')">
|
||||
<text class="share-icon-btn"></text> 分享主页
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- My Creations -->
|
||||
<view class="section-title">我的xx</view>
|
||||
<view class="menu-group">
|
||||
<view class="menu-item" @tap="navTo('avatar')">
|
||||
<view class="icon-box pink-bg"><text>☺</text></view>
|
||||
<text class="menu-text">我的专属头像</text>
|
||||
<text class="arrow">›</text>
|
||||
<!-- Menu Group 1 -->
|
||||
<view class="menu-card">
|
||||
<view class="menu-item" @tap="navTo('/pages/mine/topics')">
|
||||
<view class="icon-left"><text class="icon-bookmark"></text></view>
|
||||
<text class="menu-text">我参与的话题</text>
|
||||
<text class="menu-value" v-if="isLoggedIn">{{ userStats.participant }}</text>
|
||||
</view>
|
||||
<view class="menu-item" @tap="navTo('/pages/mine/pk')">
|
||||
<view class="icon-left"><text class="icon-bookmark"></text></view>
|
||||
<text class="menu-text">我参与的PK</text>
|
||||
<text class="menu-value" v-if="isLoggedIn">{{ userStats.pkCount }}</text>
|
||||
</view>
|
||||
<!-- <view class="menu-item" @tap="navTo('comments')">
|
||||
<view class="icon-left"><text class="icon-document"></text></view>
|
||||
<text class="menu-text">我的评论</text>
|
||||
<text class="menu-value" v-if="isLoggedIn">{{ userStats.comment }}</text>
|
||||
</view> -->
|
||||
</view>
|
||||
|
||||
<!-- Other Actions -->
|
||||
<view class="menu-group mt-30">
|
||||
<button class="menu-item share-btn" open-type="share">
|
||||
<view class="icon-left"><text class="share-icon">🔗</text></view>
|
||||
<text class="menu-text">分享给好友</text>
|
||||
<text class="arrow">›</text>
|
||||
</button>
|
||||
<!-- Menu Group 2 -->
|
||||
<view class="menu-card">
|
||||
<view class="menu-item" @tap="navTo('feedback')">
|
||||
<view class="icon-left"><text class="feedback-icon">📝</text></view>
|
||||
<view class="icon-left"><text class="icon-feedback"></text></view>
|
||||
<text class="menu-text">意见反馈</text>
|
||||
<text class="arrow">›</text>
|
||||
</view>
|
||||
<view class="menu-item" @tap="navTo('help')">
|
||||
<view class="icon-left"><text class="help-icon">❓</text></view>
|
||||
<text class="menu-text">使用说明 / 帮助</text>
|
||||
<text class="arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Footer -->
|
||||
<!-- <view class="version-info">2026 丙午马年 · 精美助手 v1.0.2</view> -->
|
||||
|
||||
|
||||
<view style="height: 120rpx"></view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
@@ -105,9 +107,10 @@
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
import { onShareAppMessage, onLoad } from "@dcloudio/uni-app";
|
||||
import { onShareAppMessage, onShareTimeline, onLoad, onShow } from "@dcloudio/uni-app";
|
||||
import { getShareToken } from "@/utils/common";
|
||||
import { getShareReward } from "@/api/system";
|
||||
import { fetchUserNums } from "@/api/mine";
|
||||
import LoginPopup from "@/components/LoginPopup/LoginPopup.vue";
|
||||
|
||||
const userStore = useUserStore();
|
||||
@@ -118,8 +121,7 @@ const navBarTop = ref(20);
|
||||
const navBarHeight = ref(44);
|
||||
|
||||
// User Info
|
||||
const defaultAvatarUrl =
|
||||
"https://file.lihailezzc.com/resource/d9b329082b32f8305101f708593a4882.png";
|
||||
const defaultAvatarUrl = "https://api.dicebear.com/7.x/avataaars/svg?seed=fallback";
|
||||
|
||||
const userInfo = computed(() => ({
|
||||
nickName: userStore.userInfo.nickName || "点击登录",
|
||||
@@ -127,32 +129,82 @@ const userInfo = computed(() => ({
|
||||
isVip: userStore.userInfo.isVip || false,
|
||||
}));
|
||||
|
||||
const isLoggedIn = computed(() => !!userStore.userInfo.nickName);
|
||||
const isLoggedIn = computed(() => !!userStore.token || !!userStore.userInfo.nickName);
|
||||
|
||||
const isIos = computed(() => uni.getSystemInfoSync().osName === "ios");
|
||||
// User Stats
|
||||
const userStats = ref({
|
||||
participant: 0,
|
||||
comment: 0,
|
||||
liked: 0,
|
||||
pkCount: 0,
|
||||
joinedDay: 0,
|
||||
label: []
|
||||
});
|
||||
|
||||
onLoad((options) => {
|
||||
const loadUserStats = async () => {
|
||||
if (!isLoggedIn.value) {
|
||||
userStats.value = {
|
||||
participant: 0,
|
||||
comment: 0,
|
||||
liked: 0,
|
||||
pkCount: 0,
|
||||
joinedDay: 0,
|
||||
label: []
|
||||
};
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetchUserNums();
|
||||
const data = res?.data || res || {};
|
||||
userStats.value = {
|
||||
participant: data.participant || 0,
|
||||
comment: data.comment || 0,
|
||||
liked: data.liked || 0,
|
||||
pkCount: data.pkCount || 0,
|
||||
joinedDay: data.joinedDay || 0,
|
||||
label: data.label || []
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("获取用户数据失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
onLoad(() => {
|
||||
const sysInfo = uni.getSystemInfoSync();
|
||||
navBarTop.value = sysInfo.statusBarHeight;
|
||||
navBarHeight.value = 44;
|
||||
});
|
||||
|
||||
onShow(() => {
|
||||
loadUserStats();
|
||||
});
|
||||
|
||||
onShareAppMessage(async () => {
|
||||
const shareToken = await getShareToken("mine");
|
||||
getShareReward({ scene: "mine" });
|
||||
const nickName = isLoggedIn.value ? userInfo.value.nickName : "我";
|
||||
return {
|
||||
title: "分享",
|
||||
path: "/pages/index/index?shareToken=" + shareToken,
|
||||
imageUrl:
|
||||
"https://file.lihailezzc.com/resource/8dd026d76ef7a63d123b7fd698fb989b.png",
|
||||
title: `${nickName}也在玩夯拉评分,来看看TA的主页`,
|
||||
path: "/pages/mine/mine?shareToken=" + shareToken,
|
||||
imageUrl: "https://file.lihailezzc.com/resource/8dd026d76ef7a63d123b7fd698fb989b.png",
|
||||
};
|
||||
});
|
||||
|
||||
onShareTimeline(async () => {
|
||||
const shareToken = await getShareToken("mine");
|
||||
getShareReward({ scene: "mine_timeline" });
|
||||
const nickName = isLoggedIn.value ? userInfo.value.nickName : "我";
|
||||
const participant = userStats.value.participant || 0;
|
||||
return {
|
||||
title: `${nickName}已参与 ${participant} 个话题,来夯拉评分一起围观`,
|
||||
query: `shareToken=${shareToken}`,
|
||||
};
|
||||
});
|
||||
|
||||
const handleUserClick = () => {
|
||||
if (!isLoggedIn.value) {
|
||||
loginPopupRef.value.open();
|
||||
openLoginPopup("请先登录");
|
||||
} else {
|
||||
// Navigate to profile details
|
||||
uni.navigateTo({
|
||||
url: "/pages/mine/profile",
|
||||
});
|
||||
@@ -160,40 +212,42 @@ const handleUserClick = () => {
|
||||
};
|
||||
|
||||
const handleLogind = async () => {
|
||||
// Logic after successful login if needed
|
||||
loadUserStats();
|
||||
};
|
||||
|
||||
const openLoginPopup = (message = "请先登录") => {
|
||||
loginPopupRef.value.open();
|
||||
if (message) {
|
||||
uni.showToast({ title: message, icon: "none" });
|
||||
}
|
||||
};
|
||||
|
||||
const navTo = (page) => {
|
||||
if (!isLoggedIn.value) {
|
||||
loginPopupRef.value.open();
|
||||
uni.showToast({ title: "请先登录", icon: "none" });
|
||||
openLoginPopup("请先登录");
|
||||
return;
|
||||
}
|
||||
|
||||
if (page === "avatar") {
|
||||
uni.navigateTo({
|
||||
url: "/pages/mine/avatar",
|
||||
});
|
||||
if (page === "profile") {
|
||||
uni.navigateTo({ url: "/pages/mine/profile" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (page === "feedback") {
|
||||
uni.navigateTo({
|
||||
url: "/pages/feedback/index",
|
||||
});
|
||||
uni.navigateTo({ url: "/pages/feedback/index" });
|
||||
return;
|
||||
}
|
||||
if (page === "help") {
|
||||
uni.navigateTo({
|
||||
url: "/pages/mine/help",
|
||||
});
|
||||
|
||||
if (page === "/pages/mine/topics") {
|
||||
uni.navigateTo({ url: page });
|
||||
return;
|
||||
}
|
||||
if (page === "vip") {
|
||||
uni.navigateTo({
|
||||
url: "/pages/mine/vip",
|
||||
});
|
||||
|
||||
if (page === "/pages/mine/pk") {
|
||||
uni.navigateTo({ url: page });
|
||||
return;
|
||||
}
|
||||
|
||||
uni.showToast({ title: "功能开发中", icon: "none" });
|
||||
};
|
||||
</script>
|
||||
@@ -201,7 +255,7 @@ const navTo = (page) => {
|
||||
<style lang="scss" scoped>
|
||||
.mine-page {
|
||||
min-height: 100vh;
|
||||
background: #f9f9f9;
|
||||
background-color: #f8f9fc;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -214,297 +268,250 @@ const navTo = (page) => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
.nav-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #000;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.content-scroll {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.content-wrap {
|
||||
padding: 20rpx 30rpx 40rpx;
|
||||
padding: 20rpx 32rpx 40rpx;
|
||||
}
|
||||
|
||||
/* User Card */
|
||||
.user-card {
|
||||
background: #fff;
|
||||
border-radius: 30rpx;
|
||||
padding: 40rpx;
|
||||
border-radius: 40rpx;
|
||||
padding: 60rpx 40rpx 40rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 40rpx;
|
||||
position: relative;
|
||||
box-shadow: 0 10rpx 30rpx rgba(0, 0, 0, 0.03);
|
||||
margin-bottom: 32rpx;
|
||||
box-shadow: 0 16rpx 40rpx rgba(77, 68, 241, 0.04);
|
||||
}
|
||||
.avatar-box {
|
||||
position: relative;
|
||||
margin-right: 30rpx;
|
||||
|
||||
.avatar-container {
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
.avatar {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 50%;
|
||||
border: 4rpx solid #fff;
|
||||
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.red-badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -10rpx;
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
background: #ff3b30;
|
||||
|
||||
.avatar-ring {
|
||||
width: 176rpx;
|
||||
height: 176rpx;
|
||||
border-radius: 50%;
|
||||
padding: 6rpx;
|
||||
background: linear-gradient(135deg, #7B46F1, #4d44f1, #00d2ff);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2rpx solid #fff;
|
||||
}
|
||||
.fire {
|
||||
font-size: 24rpx;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
flex: 1;
|
||||
.avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
border: 4rpx solid #fff;
|
||||
background-color: #fff;
|
||||
}
|
||||
.row-1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.nickname {
|
||||
font-size: 40rpx;
|
||||
font-weight: 800;
|
||||
color: #111;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
.nickname {
|
||||
font-size: 36rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
.vip-tag {
|
||||
background: #ffecec;
|
||||
padding: 4rpx 16rpx;
|
||||
border-radius: 999rpx;
|
||||
}
|
||||
.vip-text {
|
||||
color: #ff3b30;
|
||||
font-size: 20rpx;
|
||||
font-weight: bold;
|
||||
}
|
||||
.row-2 {
|
||||
|
||||
.tags-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
.arrow-icon {
|
||||
color: #ff3b30;
|
||||
font-size: 20rpx;
|
||||
|
||||
.sparkle-icon {
|
||||
font-size: 28rpx;
|
||||
color: #7B46F1;
|
||||
margin-right: 8rpx;
|
||||
}
|
||||
.stats-text {
|
||||
|
||||
.tags-text {
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
}
|
||||
.num {
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin: 0 4rpx;
|
||||
}
|
||||
.card-arrow {
|
||||
font-size: 40rpx;
|
||||
color: #ccc;
|
||||
margin-left: 10rpx;
|
||||
}
|
||||
|
||||
/* VIP Banner */
|
||||
.vip-banner {
|
||||
background: linear-gradient(135deg, #2e2424 0%, #1a1616 100%);
|
||||
border-radius: 30rpx;
|
||||
padding: 30rpx 40rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 40rpx;
|
||||
box-shadow: 0 10rpx 30rpx rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.vip-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.vip-icon-box {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
background: #fceea9;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
.joined-day-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 24rpx;
|
||||
border: 4rpx solid #bfa46f;
|
||||
gap: 8rpx;
|
||||
height: 52rpx;
|
||||
padding: 0 20rpx;
|
||||
margin-bottom: 36rpx;
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(135deg, rgba(91, 84, 223, 0.12), rgba(69, 59, 199, 0.05));
|
||||
box-shadow: inset 0 0 0 2rpx rgba(91, 84, 223, 0.08);
|
||||
}
|
||||
|
||||
.vip-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.vip-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.vip-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #fceea9;
|
||||
margin-right: 12rpx;
|
||||
}
|
||||
|
||||
.diamond-tag {
|
||||
background: rgba(252, 238, 169, 0.15);
|
||||
color: #fceea9;
|
||||
font-size: 18rpx;
|
||||
padding: 2rpx 8rpx;
|
||||
border-radius: 6rpx;
|
||||
border: 1px solid rgba(252, 238, 169, 0.4);
|
||||
}
|
||||
|
||||
.vip-subtitle {
|
||||
.joined-day-label {
|
||||
font-size: 22rpx;
|
||||
color: rgba(252, 238, 169, 0.6);
|
||||
}
|
||||
|
||||
.vip-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.open-text {
|
||||
font-size: 26rpx;
|
||||
color: #fceea9;
|
||||
margin-right: 4rpx;
|
||||
color: #7b8191;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.vip-banner .arrow {
|
||||
color: #fceea9;
|
||||
font-size: 32rpx;
|
||||
.joined-day-value {
|
||||
font-size: 28rpx;
|
||||
color: #453bc7;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
/* Section Title */
|
||||
.section-title {
|
||||
font-size: 26rpx;
|
||||
font-weight: bold;
|
||||
color: #999;
|
||||
margin-bottom: 20rpx;
|
||||
padding-left: 10rpx;
|
||||
}
|
||||
|
||||
/* Menu Group */
|
||||
.menu-group {
|
||||
background: #fff;
|
||||
border-radius: 24rpx;
|
||||
overflow: hidden;
|
||||
margin-bottom: 40rpx;
|
||||
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
.menu-group.mt-30 {
|
||||
margin-top: 30rpx;
|
||||
}
|
||||
.menu-item {
|
||||
.stats-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 30rpx;
|
||||
background: #fff;
|
||||
position: relative;
|
||||
}
|
||||
.menu-item:active {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
/* For button reset */
|
||||
.menu-item.share-btn {
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
line-height: normal;
|
||||
border-radius: 0;
|
||||
margin-bottom: 48rpx;
|
||||
}
|
||||
.menu-item.share-btn::after {
|
||||
|
||||
.stat-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-num {
|
||||
font-size: 40rpx;
|
||||
font-weight: 800;
|
||||
color: #111;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 24rpx;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.stat-divider {
|
||||
width: 2rpx;
|
||||
height: 40rpx;
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
background: linear-gradient(135deg, #5b54df, #453bc7);
|
||||
color: #fff;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
border-radius: 100rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8rpx 20rpx rgba(77, 68, 241, 0.2);
|
||||
}
|
||||
|
||||
.btn-primary::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.icon-box {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 16rpx;
|
||||
.btn-secondary {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
background-color: #f3f2ff;
|
||||
color: #453bc7;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
border-radius: 100rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-secondary::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.share-icon-btn {
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23453bc7' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='18' cy='5' r='3'%3E%3C/circle%3E%3Ccircle cx='6' cy='12' r='3'%3E%3C/circle%3E%3Ccircle cx='18' cy='19' r='3'%3E%3C/circle%3E%3Cline x1='8.59' y1='13.51' x2='15.42' y2='17.49'%3E%3C/line%3E%3Cline x1='15.41' y1='6.51' x2='8.59' y2='10.49'%3E%3C/line%3E%3C/svg%3E");
|
||||
background-size: cover;
|
||||
margin-right: 12rpx;
|
||||
}
|
||||
|
||||
/* Menu Card */
|
||||
.menu-card {
|
||||
background: #fff;
|
||||
border-radius: 32rpx;
|
||||
padding: 16rpx 0;
|
||||
margin-bottom: 24rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 32rpx 40rpx;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.menu-item:active {
|
||||
background: #fcfcfc;
|
||||
}
|
||||
|
||||
.icon-left {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 24rpx;
|
||||
font-size: 36rpx;
|
||||
}
|
||||
.red-bg {
|
||||
background: #fff0f0;
|
||||
color: #ff3b30;
|
||||
}
|
||||
.orange-bg {
|
||||
background: #fff8e6;
|
||||
color: #ff9500;
|
||||
}
|
||||
.pink-bg {
|
||||
background: #fff0f5;
|
||||
color: #ff2d55;
|
||||
}
|
||||
.yellow-bg {
|
||||
background: #fffae0;
|
||||
color: #ffcc00;
|
||||
}
|
||||
.gray-bg {
|
||||
background: #f5f5f5;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.icon-left {
|
||||
.icon-bookmark {
|
||||
width: 40rpx;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-right: 24rpx;
|
||||
margin-left: 16rpx;
|
||||
height: 40rpx;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%234d44f1' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z'%3E%3C/path%3E%3C/svg%3E");
|
||||
background-size: cover;
|
||||
}
|
||||
.share-icon,
|
||||
.help-icon,
|
||||
.feedback-icon {
|
||||
font-size: 36rpx;
|
||||
color: #666;
|
||||
|
||||
.icon-document {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%237B46F1' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z'%3E%3C/path%3E%3Cpolyline points='14 2 14 8 20 8'%3E%3C/polyline%3E%3Cline x1='16' y1='13' x2='8' y2='13'%3E%3C/line%3E%3Cline x1='16' y1='17' x2='8' y2='17'%3E%3C/line%3E%3Cpolyline points='10 9 9 9 8 9'%3E%3C/polyline%3E%3C/svg%3E");
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.icon-feedback {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23453bc7' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z'%3E%3C/path%3E%3Cline x1='9' y1='10' x2='15' y2='10'%3E%3C/line%3E%3C/svg%3E");
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.menu-text {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
font-size: 30rpx;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
.new-badge {
|
||||
background: #ff3b30;
|
||||
color: #fff;
|
||||
font-size: 20rpx;
|
||||
padding: 2rpx 10rpx;
|
||||
border-radius: 8rpx;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
.arrow {
|
||||
color: #ccc;
|
||||
|
||||
.menu-value {
|
||||
font-size: 32rpx;
|
||||
color: #999;
|
||||
font-family: 'DIN Alternate', -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
/* Version Info */
|
||||
.version-info {
|
||||
text-align: center;
|
||||
.arrow {
|
||||
color: #ccc;
|
||||
font-size: 22rpx;
|
||||
margin-top: 60rpx;
|
||||
font-size: 36rpx;
|
||||
margin-left: 12rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
805
pages/mine/pk.vue
Normal file
@@ -0,0 +1,805 @@
|
||||
<template>
|
||||
<view class="page-container">
|
||||
<view class="nav-bar" :style="{ paddingTop: statusBarHeight + 'px' }">
|
||||
<view class="nav-content">
|
||||
<view class="nav-left" @tap="goBack">
|
||||
<text class="back-icon"></text>
|
||||
<text class="nav-title">我参与的PK</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view :style="{ height: statusBarHeight + 44 + 'px' }"></view>
|
||||
|
||||
<view class="content-wrap">
|
||||
<!-- <view class="hero-card">
|
||||
<view class="hero-main">
|
||||
<text class="hero-badge">PK Record</text>
|
||||
<text class="hero-title">我的站队记录</text>
|
||||
<text class="hero-desc">回看你参与过的每一场 PK,对比风向,看看你是大众派还是眼光独到。</text>
|
||||
</view>
|
||||
<view class="hero-stats">
|
||||
<view class="hero-stat">
|
||||
<text class="hero-stat-num">{{ isLoggedIn ? totalCount : "-" }}</text>
|
||||
<text class="hero-stat-label">参与场次</text>
|
||||
</view>
|
||||
<view class="hero-stat-divider"></view>
|
||||
<view class="hero-stat">
|
||||
<text class="hero-stat-num">{{ supportWinCount }}</text>
|
||||
<text class="hero-stat-label">站队领先</text>
|
||||
</view>
|
||||
</view>
|
||||
</view> -->
|
||||
|
||||
<view v-if="!isLoggedIn" class="state-card">
|
||||
<text class="state-title">登录后查看你的 PK 足迹</text>
|
||||
<text class="state-desc">你参与过的 PK 对局、站队结果和支持率都会记录在这里。</text>
|
||||
<button class="state-btn" @tap="openLoginPopup('请先登录')">立即登录</button>
|
||||
</view>
|
||||
|
||||
<view v-else-if="loading && !pkList.length" class="state-card">
|
||||
<text class="state-title">正在加载 PK 记录...</text>
|
||||
<text class="state-desc">马上为你整理最近参与过的对决</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="loadError && !pkList.length" class="state-card">
|
||||
<text class="state-title">加载失败</text>
|
||||
<text class="state-desc">{{ loadError }}</text>
|
||||
<button class="state-btn" @tap="refreshList">重新加载</button>
|
||||
</view>
|
||||
|
||||
<view v-else-if="!pkList.length" class="state-card empty-card">
|
||||
<view class="empty-orbit orbit-1"></view>
|
||||
<view class="empty-orbit orbit-2"></view>
|
||||
<view class="empty-core">
|
||||
<text class="empty-emoji">⚔️</text>
|
||||
</view>
|
||||
<text class="state-title">你还没有参与任何 PK</text>
|
||||
<text class="state-desc">去 PK 站挑一场神仙打架,留下你的第一条站队记录吧。</text>
|
||||
<button class="state-btn" @tap="goToPkStation">去 PK 站看看</button>
|
||||
</view>
|
||||
|
||||
<view v-else class="pk-list">
|
||||
<view
|
||||
v-for="item in pkList"
|
||||
:key="item.id"
|
||||
class="pk-card"
|
||||
@tap="goToPkDetail(item)"
|
||||
>
|
||||
<view class="pk-card-top">
|
||||
<view class="pk-topic-wrap">
|
||||
<text class="pk-topic-label">话题</text>
|
||||
<text class="pk-topic-title">{{ item.topicTitle }}</text>
|
||||
</view>
|
||||
<view class="pk-time-chip">
|
||||
<text class="pk-time-text">{{ item.votedAtText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="battle-stage">
|
||||
<view class="fighter-card left" :class="{ selected: item.selfChooseSide === 'left' }">
|
||||
<view class="fighter-badge" v-if="item.selfChooseSide === 'left'">我站这边</view>
|
||||
<image class="fighter-avatar" :src="item.left.avatar" mode="aspectFill" />
|
||||
<text class="fighter-name">{{ item.left.name }}</text>
|
||||
<text class="fighter-votes">{{ item.leftVoteCount }} 票</text>
|
||||
</view>
|
||||
|
||||
<view class="battle-middle">
|
||||
<view class="vs-badge">VS</view>
|
||||
<text class="battle-middle-text">{{ item.voteCount }} 人参与</text>
|
||||
</view>
|
||||
|
||||
<view class="fighter-card right" :class="{ selected: item.selfChooseSide === 'right' }">
|
||||
<view class="fighter-badge fighter-badge--right" v-if="item.selfChooseSide === 'right'">我站这边</view>
|
||||
<image class="fighter-avatar" :src="item.right.avatar" mode="aspectFill" />
|
||||
<text class="fighter-name">{{ item.right.name }}</text>
|
||||
<text class="fighter-votes">{{ item.rightVoteCount }} 票</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="support-bar">
|
||||
<view class="support-fill left-fill" :style="{ width: `${item.leftPercent}%` }"></view>
|
||||
<view class="support-fill right-fill" :style="{ width: `${item.rightPercent}%` }"></view>
|
||||
</view>
|
||||
|
||||
<view class="support-row">
|
||||
<text class="support-text left-text">{{ item.leftPercent }}% 支持 {{ item.left.name }}</text>
|
||||
<text class="support-text right-text">{{ item.rightPercent }}% 支持 {{ item.right.name }}</text>
|
||||
</view>
|
||||
|
||||
<view class="pk-card-footer">
|
||||
<view class="choice-pill" :class="item.selfChooseSide === 'right' ? 'choice-pill--right' : ''">
|
||||
<text class="choice-pill-label">我的选择</text>
|
||||
<text class="choice-pill-value">{{ item.selfChooseName }}</text>
|
||||
</view>
|
||||
<text class="footer-link">继续查看</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="pkList.length" class="load-more">
|
||||
<text>{{ loading ? '加载中...' : (hasMore ? '上拉加载更多' : '没有更多了') }}</text>
|
||||
</view>
|
||||
|
||||
<LoginPopup ref="loginPopupRef" @logind="handleLoginSuccess" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onPullDownRefresh, onReachBottom, onShow } from "@dcloudio/uni-app";
|
||||
import { getStatusBarHeight } from "@/utils/system";
|
||||
import { fetchUserPkList } from "@/api/pk";
|
||||
import { FILE_BASE_URL } from "@/utils/constants";
|
||||
import { formatDate } from "@/utils/date";
|
||||
import LoginPopup from "@/components/LoginPopup/LoginPopup.vue";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
|
||||
const userStore = useUserStore();
|
||||
const loginPopupRef = ref(null);
|
||||
const statusBarHeight = ref(getStatusBarHeight() || 44);
|
||||
const pkList = ref([]);
|
||||
const loading = ref(false);
|
||||
const hasMore = ref(true);
|
||||
const page = ref(1);
|
||||
const totalCount = ref(0);
|
||||
const loadError = ref("");
|
||||
|
||||
const isLoggedIn = computed(() => !!userStore.token || !!userStore.userInfo?.nickName);
|
||||
const supportWinCount = computed(() => {
|
||||
return pkList.value.filter((item) => {
|
||||
if (item.selfChooseSide === "left") return item.leftPercent >= item.rightPercent;
|
||||
if (item.selfChooseSide === "right") return item.rightPercent >= item.leftPercent;
|
||||
return false;
|
||||
}).length;
|
||||
});
|
||||
|
||||
const buildAvatarUrl = (avatar) => {
|
||||
if (!avatar) return "https://api.dicebear.com/7.x/avataaars/svg?seed=fallback";
|
||||
const normalizedAvatar = String(avatar).trim();
|
||||
return normalizedAvatar.startsWith("http") ? normalizedAvatar : `${FILE_BASE_URL}${normalizedAvatar}`;
|
||||
};
|
||||
|
||||
const clampPercent = (value) => {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return 0;
|
||||
return Math.max(0, Math.min(100, Math.round(number)));
|
||||
};
|
||||
|
||||
const normalizePercentPair = (item) => {
|
||||
let leftPercent = clampPercent(item.leftPercent);
|
||||
let rightPercent = clampPercent(item.rightPercent);
|
||||
|
||||
if (!leftPercent && !rightPercent) {
|
||||
const leftVotes = Number(item.leftVoteCount || 0);
|
||||
const rightVotes = Number(item.rightVoteCount || 0);
|
||||
const totalVotes = leftVotes + rightVotes;
|
||||
if (totalVotes > 0) {
|
||||
leftPercent = Math.round((leftVotes / totalVotes) * 100);
|
||||
rightPercent = 100 - leftPercent;
|
||||
} else {
|
||||
leftPercent = 50;
|
||||
rightPercent = 50;
|
||||
}
|
||||
} else if (!leftPercent) {
|
||||
leftPercent = 100 - rightPercent;
|
||||
} else if (!rightPercent) {
|
||||
rightPercent = 100 - leftPercent;
|
||||
}
|
||||
|
||||
const total = leftPercent + rightPercent;
|
||||
if (total && total !== 100) {
|
||||
leftPercent = Math.round((leftPercent / total) * 100);
|
||||
rightPercent = 100 - leftPercent;
|
||||
}
|
||||
|
||||
return { leftPercent, rightPercent };
|
||||
};
|
||||
|
||||
const normalizePkItem = (item = {}) => {
|
||||
const { leftPercent, rightPercent } = normalizePercentPair(item);
|
||||
const selfChooseSide =
|
||||
item.selfChooseSide ||
|
||||
(item.selfChooseItemId && item.selfChooseItemId === item.leftItemId
|
||||
? "left"
|
||||
: item.selfChooseItemId && item.selfChooseItemId === item.rightItemId
|
||||
? "right"
|
||||
: "");
|
||||
const selfChooseName =
|
||||
selfChooseSide === "right"
|
||||
? (item.rightItemName || "右侧对象")
|
||||
: selfChooseSide === "left"
|
||||
? (item.leftItemName || "左侧对象")
|
||||
: "未记录";
|
||||
|
||||
return {
|
||||
id: item.id || "",
|
||||
topicId: item.topicId || "",
|
||||
topicTitle: item.topicTitle || "未知话题",
|
||||
voteCount: Number(item.voteCount || 0),
|
||||
leftVoteCount: Number(item.leftVoteCount || 0),
|
||||
rightVoteCount: Number(item.rightVoteCount || 0),
|
||||
selfChooseItemId: item.selfChooseItemId || "",
|
||||
selfChooseSide,
|
||||
selfChooseName,
|
||||
leftPercent,
|
||||
rightPercent,
|
||||
votedAt: item.votedAt || "",
|
||||
votedAtText: formatDate(item.votedAt, "YYYY-MM-DD HH:mm") || "刚刚参与",
|
||||
left: {
|
||||
id: item.leftItemId || "",
|
||||
name: item.leftItemName || "左侧对象",
|
||||
avatar: buildAvatarUrl(item.leftItemAvatar),
|
||||
},
|
||||
right: {
|
||||
id: item.rightItemId || "",
|
||||
name: item.rightItemName || "右侧对象",
|
||||
avatar: buildAvatarUrl(item.rightItemAvatar),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
uni.navigateBack();
|
||||
};
|
||||
|
||||
const openLoginPopup = (message = "请先登录") => {
|
||||
loginPopupRef.value?.open();
|
||||
if (message) {
|
||||
uni.showToast({ title: message, icon: "none" });
|
||||
}
|
||||
};
|
||||
|
||||
const goToPkStation = () => {
|
||||
uni.switchTab({
|
||||
url: "/pages/pk/index",
|
||||
});
|
||||
};
|
||||
|
||||
const goToPkDetail = (item) => {
|
||||
if (!item?.id) return;
|
||||
const cacheKey = `pk_detail_cache_${item.id}`;
|
||||
uni.setStorageSync(cacheKey, {
|
||||
id: item.id,
|
||||
topicId: item.topicId,
|
||||
topicTitle: item.topicTitle,
|
||||
leftItemId: item.left.id,
|
||||
leftItemName: item.left.name,
|
||||
leftItemAvatar: item.left.avatar,
|
||||
rightItemId: item.right.id,
|
||||
rightItemName: item.right.name,
|
||||
rightItemAvatar: item.right.avatar,
|
||||
voteCount: item.voteCount,
|
||||
leftVoteCount: item.leftVoteCount,
|
||||
rightVoteCount: item.rightVoteCount,
|
||||
selfChooseItemId: item.selfChooseItemId,
|
||||
selfChooseSide: item.selfChooseSide,
|
||||
leftPercent: item.leftPercent,
|
||||
rightPercent: item.rightPercent,
|
||||
votedAt: item.votedAt,
|
||||
});
|
||||
uni.navigateTo({
|
||||
url: `/pages/pk/detail?pkId=${item.id}&cacheKey=${cacheKey}`,
|
||||
});
|
||||
};
|
||||
|
||||
const loadList = async (targetPage = 1) => {
|
||||
if (!isLoggedIn.value || loading.value) return;
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
const res = await fetchUserPkList({ page: targetPage });
|
||||
const data = res?.data || res || {};
|
||||
const rawList = data.list || res?.list || [];
|
||||
const list = Array.isArray(rawList) ? rawList : [];
|
||||
const formattedList = list.map(normalizePkItem);
|
||||
const nextTotalCount = Number(data.totalCount || res?.totalCount || 0);
|
||||
|
||||
totalCount.value = nextTotalCount;
|
||||
pkList.value = targetPage === 1 ? formattedList : [...pkList.value, ...formattedList];
|
||||
|
||||
if (nextTotalCount > 0) {
|
||||
hasMore.value = pkList.value.length < nextTotalCount;
|
||||
} else {
|
||||
hasMore.value = list.length > 0;
|
||||
}
|
||||
} catch (error) {
|
||||
loadError.value = "网络开了点小差,稍后重试";
|
||||
if (targetPage > 1) {
|
||||
page.value -= 1;
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const refreshList = async () => {
|
||||
page.value = 1;
|
||||
hasMore.value = true;
|
||||
await loadList(1);
|
||||
};
|
||||
|
||||
const handleLoginSuccess = async () => {
|
||||
await refreshList();
|
||||
};
|
||||
|
||||
onLoad(() => {});
|
||||
|
||||
onShow(() => {
|
||||
if (!isLoggedIn.value) {
|
||||
pkList.value = [];
|
||||
totalCount.value = 0;
|
||||
hasMore.value = false;
|
||||
return;
|
||||
}
|
||||
refreshList();
|
||||
});
|
||||
|
||||
onPullDownRefresh(async () => {
|
||||
if (isLoggedIn.value) {
|
||||
await refreshList();
|
||||
}
|
||||
uni.stopPullDownRefresh();
|
||||
});
|
||||
|
||||
onReachBottom(() => {
|
||||
if (!isLoggedIn.value || !hasMore.value || loading.value) return;
|
||||
page.value += 1;
|
||||
loadList(page.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-container {
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(111, 90, 255, 0.12), transparent 30%),
|
||||
linear-gradient(180deg, #f7f8ff 0%, #f6f7fb 42%, #ffffff 100%);
|
||||
padding-bottom: 56rpx;
|
||||
}
|
||||
|
||||
.nav-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
background: rgba(247, 248, 255, 0.88);
|
||||
backdrop-filter: blur(24rpx);
|
||||
}
|
||||
|
||||
.nav-content {
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 32rpx;
|
||||
}
|
||||
|
||||
.nav-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.back-icon {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23202433' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='19' y1='12' x2='5' y2='12'%3E%3C/line%3E%3Cpolyline points='12 19 5 12 12 5'%3E%3C/polyline%3E%3C/svg%3E");
|
||||
background-size: cover;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 800;
|
||||
color: #202433;
|
||||
}
|
||||
|
||||
.content-wrap {
|
||||
padding: 24rpx 32rpx 0;
|
||||
}
|
||||
|
||||
.hero-card,
|
||||
.pk-card,
|
||||
.state-card {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 2rpx solid rgba(229, 232, 246, 0.92);
|
||||
border-radius: 32rpx;
|
||||
box-shadow: 0 18rpx 42rpx rgba(87, 92, 145, 0.08);
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
padding: 32rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.hero-main {
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.hero-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 44rpx;
|
||||
padding: 0 18rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(92, 67, 245, 0.1);
|
||||
color: #5c43f5;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
margin-bottom: 18rpx;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
display: block;
|
||||
font-size: 42rpx;
|
||||
font-weight: 800;
|
||||
color: #1f2432;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.hero-desc {
|
||||
font-size: 25rpx;
|
||||
color: #7b8191;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24rpx 20rpx 8rpx;
|
||||
border-radius: 24rpx;
|
||||
background: linear-gradient(135deg, rgba(92, 67, 245, 0.08), rgba(92, 67, 245, 0.02));
|
||||
}
|
||||
|
||||
.hero-stat {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-stat-num {
|
||||
display: block;
|
||||
font-size: 38rpx;
|
||||
font-weight: 800;
|
||||
color: #202433;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.hero-stat-label {
|
||||
font-size: 22rpx;
|
||||
color: #8a90a0;
|
||||
}
|
||||
|
||||
.hero-stat-divider {
|
||||
width: 2rpx;
|
||||
height: 52rpx;
|
||||
background: rgba(206, 211, 229, 0.8);
|
||||
}
|
||||
|
||||
.state-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 64rpx 40rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.state-title {
|
||||
display: block;
|
||||
font-size: 32rpx;
|
||||
color: #232734;
|
||||
font-weight: 800;
|
||||
margin-bottom: 14rpx;
|
||||
}
|
||||
|
||||
.state-desc {
|
||||
display: block;
|
||||
font-size: 25rpx;
|
||||
color: #8a90a0;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
|
||||
.state-btn {
|
||||
width: 240rpx;
|
||||
height: 84rpx;
|
||||
line-height: 84rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #111827;
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.state-btn::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.empty-card {
|
||||
padding-top: 92rpx;
|
||||
}
|
||||
|
||||
.empty-orbit {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
border: 2rpx dashed rgba(99, 102, 241, 0.16);
|
||||
}
|
||||
|
||||
.orbit-1 {
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
top: 26rpx;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.orbit-2 {
|
||||
width: 140rpx;
|
||||
height: 140rpx;
|
||||
top: 56rpx;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.empty-core {
|
||||
width: 112rpx;
|
||||
height: 112rpx;
|
||||
margin: 0 auto 130rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #eef2ff 100%);
|
||||
box-shadow: 0 16rpx 34rpx rgba(99, 102, 241, 0.14);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.empty-emoji {
|
||||
font-size: 52rpx;
|
||||
}
|
||||
|
||||
.pk-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.pk-card {
|
||||
padding: 28rpx;
|
||||
}
|
||||
|
||||
.pk-card-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.pk-topic-wrap {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pk-topic-label {
|
||||
display: block;
|
||||
font-size: 22rpx;
|
||||
color: #8a90a0;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.pk-topic-title {
|
||||
display: block;
|
||||
font-size: 30rpx;
|
||||
line-height: 1.45;
|
||||
color: #202433;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.pk-time-chip {
|
||||
flex-shrink: 0;
|
||||
padding: 10rpx 16rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(17, 24, 39, 0.06);
|
||||
}
|
||||
|
||||
.pk-time-text {
|
||||
font-size: 22rpx;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.battle-stage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12rpx;
|
||||
margin-bottom: 22rpx;
|
||||
}
|
||||
|
||||
.fighter-card {
|
||||
position: relative;
|
||||
width: 224rpx;
|
||||
min-height: 276rpx;
|
||||
border-radius: 28rpx;
|
||||
padding: 30rpx 18rpx 24rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(180deg, #fafbff 0%, #ffffff 100%);
|
||||
border: 2rpx solid transparent;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.fighter-card.left {
|
||||
border-color: rgba(92, 67, 245, 0.18);
|
||||
}
|
||||
|
||||
.fighter-card.right {
|
||||
border-color: rgba(255, 153, 102, 0.2);
|
||||
}
|
||||
|
||||
.fighter-card.selected {
|
||||
transform: translateY(-4rpx);
|
||||
box-shadow: 0 14rpx 28rpx rgba(92, 67, 245, 0.12);
|
||||
}
|
||||
|
||||
.fighter-badge {
|
||||
position: absolute;
|
||||
top: 16rpx;
|
||||
left: 16rpx;
|
||||
padding: 8rpx 14rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(92, 67, 245, 0.12);
|
||||
color: #5c43f5;
|
||||
font-size: 20rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.fighter-badge--right {
|
||||
background: rgba(255, 141, 95, 0.14);
|
||||
color: #ff7a45;
|
||||
}
|
||||
|
||||
.fighter-avatar {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 50%;
|
||||
border: 6rpx solid #fff;
|
||||
box-shadow: 0 8rpx 20rpx rgba(0, 0, 0, 0.08);
|
||||
margin-bottom: 18rpx;
|
||||
}
|
||||
|
||||
.fighter-name {
|
||||
font-size: 30rpx;
|
||||
color: #1f2432;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.fighter-votes {
|
||||
font-size: 22rpx;
|
||||
color: #858b9b;
|
||||
}
|
||||
|
||||
.battle-middle {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vs-badge {
|
||||
width: 90rpx;
|
||||
height: 90rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #6a5bff, #4d44f1);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28rpx;
|
||||
font-weight: 900;
|
||||
box-shadow: 0 16rpx 30rpx rgba(92, 67, 245, 0.24);
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.battle-middle-text {
|
||||
font-size: 22rpx;
|
||||
color: #8a90a0;
|
||||
}
|
||||
|
||||
.support-bar {
|
||||
height: 18rpx;
|
||||
border-radius: 999rpx;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
background: #eceff8;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.support-fill {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.left-fill {
|
||||
background: linear-gradient(90deg, #6557ff, #4d44f1);
|
||||
}
|
||||
|
||||
.right-fill {
|
||||
background: linear-gradient(90deg, #ffb15f, #ff8d5f);
|
||||
}
|
||||
|
||||
.support-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
margin-bottom: 22rpx;
|
||||
}
|
||||
|
||||
.support-text {
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.left-text {
|
||||
color: #5c43f5;
|
||||
}
|
||||
|
||||
.right-text {
|
||||
color: #ff8d5f;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.pk-card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.choice-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
padding: 12rpx 18rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(92, 67, 245, 0.1);
|
||||
}
|
||||
|
||||
.choice-pill--right {
|
||||
background: rgba(255, 141, 95, 0.12);
|
||||
}
|
||||
|
||||
.choice-pill-label {
|
||||
font-size: 22rpx;
|
||||
color: #8a90a0;
|
||||
}
|
||||
|
||||
.choice-pill-value {
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
color: #4d44f1;
|
||||
}
|
||||
|
||||
.choice-pill--right .choice-pill-value {
|
||||
color: #ff7a45;
|
||||
}
|
||||
|
||||
.footer-link {
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.load-more {
|
||||
text-align: center;
|
||||
padding: 24rpx 0 40rpx;
|
||||
color: #999;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
</style>
|
||||
236
pages/mine/topics.vue
Normal file
@@ -0,0 +1,236 @@
|
||||
<template>
|
||||
<view class="page-container">
|
||||
<!-- 顶部导航栏 -->
|
||||
<view class="nav-bar" :style="{ paddingTop: statusBarHeight + 'px' }">
|
||||
<view class="nav-content">
|
||||
<view class="nav-left" @tap="goBack">
|
||||
<text class="back-icon"></text>
|
||||
<text class="nav-title">我参与的话题</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 占位防止内容被导航栏遮挡 -->
|
||||
<view :style="{ height: statusBarHeight + 44 + 'px' }"></view>
|
||||
|
||||
<!-- 话题列表 -->
|
||||
<view class="topic-list-wrap">
|
||||
<view v-if="topics.length > 0">
|
||||
<TopicCard
|
||||
v-for="(item, index) in topics"
|
||||
:key="item.id || index"
|
||||
:topic="item"
|
||||
@item-click="goToTopicDetail(item.id)"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<view class="empty-state" v-if="!loading && topics.length === 0">
|
||||
<text class="empty-icon">📝</text>
|
||||
<text class="empty-text">你还没有参与任何话题</text>
|
||||
<view class="empty-btn" @tap="goToHome">去逛逛</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 加载更多指示器 -->
|
||||
<view class="load-more" v-if="topics.length > 0">
|
||||
<text>{{ loading ? '加载中...' : (hasMore ? '上拉加载更多' : '没有更多了') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { getStatusBarHeight } from "@/utils/system";
|
||||
import { onLoad, onPullDownRefresh, onReachBottom, onShow } from "@dcloudio/uni-app";
|
||||
import { fetchUserTopics } from "@/api/topic";
|
||||
import TopicCard from "@/components/TopicCard/TopicCard.vue";
|
||||
|
||||
const statusBarHeight = ref(getStatusBarHeight() || 44);
|
||||
const topics = ref([]);
|
||||
const loading = ref(false);
|
||||
const hasMore = ref(true);
|
||||
const currentPage = ref(1);
|
||||
const pageSize = 5; // 每次获取5个
|
||||
|
||||
const goBack = () => {
|
||||
uni.navigateBack();
|
||||
};
|
||||
|
||||
const goToHome = () => {
|
||||
uni.switchTab({
|
||||
url: '/pages/index/index'
|
||||
});
|
||||
};
|
||||
|
||||
const goToTopicDetail = (topicId) => {
|
||||
uni.navigateTo({
|
||||
url: `/pages/rating/index?topicId=${topicId}`
|
||||
});
|
||||
};
|
||||
|
||||
const loadData = async (page = 1) => {
|
||||
if (loading.value) return;
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
const res = await fetchUserTopics(page);
|
||||
const list = res?.list || [];
|
||||
|
||||
// 如果是第一页,先清空原有数据
|
||||
if (page === 1) {
|
||||
topics.value = [];
|
||||
}
|
||||
|
||||
// 构造适合 TopicCard 渲染的数据格式
|
||||
// const formattedList = list.map(item => ({
|
||||
// id: item.topicId || item.id,
|
||||
// title: item.topicName || item.title || '未知话题',
|
||||
// image: item.image || item.coverUrl || 'https://api.dicebear.com/7.x/shapes/svg?seed=' + (item.topicId || item.id), // 给个默认图防止报错
|
||||
// views: item.participantCount || item.views || 0,
|
||||
// items: item.items || item.ratingItems || []
|
||||
// }));
|
||||
|
||||
topics.value = [...topics.value, ...list];
|
||||
|
||||
// 判断是否有下一页
|
||||
hasMore.value = list.length >= pageSize;
|
||||
} catch (error) {
|
||||
console.error("获取参与话题列表失败:", error);
|
||||
uni.showToast({
|
||||
title: '获取列表失败',
|
||||
icon: 'none'
|
||||
});
|
||||
if (page === 1) {
|
||||
hasMore.value = false;
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onLoad(() => {
|
||||
// onLoad 只触发一次
|
||||
});
|
||||
|
||||
onShow(() => {
|
||||
// 每次进入页面都刷新,确保数据是最新的(如果刚才在详情页做了评分)
|
||||
loadData(1);
|
||||
});
|
||||
|
||||
onPullDownRefresh(async () => {
|
||||
currentPage.value = 1;
|
||||
hasMore.value = true;
|
||||
await loadData(1);
|
||||
uni.stopPullDownRefresh();
|
||||
});
|
||||
|
||||
onReachBottom(() => {
|
||||
if (!hasMore.value || loading.value) return;
|
||||
currentPage.value += 1;
|
||||
loadData(currentPage.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-container {
|
||||
min-height: 100vh;
|
||||
background-color: #f5f6fa;
|
||||
padding-bottom: 60rpx;
|
||||
}
|
||||
|
||||
/* 导航栏 */
|
||||
.nav-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
background-color: #f5f6fa;
|
||||
backdrop-filter: blur(20rpx);
|
||||
}
|
||||
|
||||
.nav-content {
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 32rpx;
|
||||
}
|
||||
|
||||
.nav-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.back-icon {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23333' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='19' y1='12' x2='5' y2='12'%3E%3C/line%3E%3Cpolyline points='12 19 5 12 12 5'%3E%3C/polyline%3E%3C/svg%3E");
|
||||
background-size: cover;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
color: #111;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Helvetica, Segoe UI, Arial, Roboto, 'PingFang SC', 'miui', 'Hiragino Sans GB', 'Microsoft Yahei', sans-serif;
|
||||
}
|
||||
|
||||
/* 列表区 */
|
||||
.topic-list-wrap {
|
||||
padding: 24rpx 32rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx; /* 组件之间的间距 */
|
||||
}
|
||||
|
||||
/* 覆盖 TopicCard 的外边距,让外层控制间距 */
|
||||
:deep(.topic-card) {
|
||||
margin-bottom: 24rpx !important;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 160rpx 0;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 100rpx;
|
||||
margin-bottom: 24rpx;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 28rpx;
|
||||
color: #888;
|
||||
margin-bottom: 48rpx;
|
||||
}
|
||||
|
||||
.empty-btn {
|
||||
background: linear-gradient(135deg, #4d44f1, #2953ff);
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
padding: 16rpx 64rpx;
|
||||
border-radius: 100rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(41, 83, 255, 0.25);
|
||||
}
|
||||
|
||||
.empty-btn:active {
|
||||
transform: scale(0.95);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
/* 加载更多 */
|
||||
.load-more {
|
||||
text-align: center;
|
||||
padding: 20rpx 0 40rpx;
|
||||
color: #999;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
</style>
|
||||
712
pages/pk/detail.vue
Normal file
@@ -0,0 +1,712 @@
|
||||
<template>
|
||||
<view class="page-container">
|
||||
<view class="nav-bar" :style="{ paddingTop: statusBarHeight + 'px' }">
|
||||
<view class="nav-content">
|
||||
<view class="nav-left" @tap="goBack">
|
||||
<text class="back-icon"></text>
|
||||
</view>
|
||||
<text class="nav-title">PK详情</text>
|
||||
<view class="nav-right" @tap="goToPkStation">
|
||||
<text class="nav-right-text">PK站</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view :style="{ height: statusBarHeight + 44 + 'px' }"></view>
|
||||
|
||||
<view class="content-wrap">
|
||||
<view v-if="detailData" class="hero-card">
|
||||
<view class="hero-badge">PK Detail</view>
|
||||
<text class="hero-title">{{ detailData.topicTitle }}</text>
|
||||
<text class="hero-desc">{{ insightText }}</text>
|
||||
|
||||
<view class="summary-row">
|
||||
<view class="summary-pill" :class="selectedTone ? `summary-pill--${selectedTone}` : ''">
|
||||
<text class="summary-pill-label">我的选择</text>
|
||||
<text class="summary-pill-value">{{ selectedItemLabel }}</text>
|
||||
</view>
|
||||
<view class="summary-meta">
|
||||
<text class="summary-meta-item">{{ detailData.voteCount }} 人参与</text>
|
||||
<text class="summary-dot"></text>
|
||||
<text class="summary-meta-item">{{ detailData.votedAtText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="detailData" class="battle-card">
|
||||
<view class="battle-stage">
|
||||
<view class="fighter-card left" :class="{ selected: detailData.selfChooseSide === 'left' }">
|
||||
<view class="fighter-badge" v-if="detailData.selfChooseSide === 'left'">我支持</view>
|
||||
<image class="fighter-avatar" :src="detailData.left.avatar" mode="aspectFill" />
|
||||
<text class="fighter-name">{{ detailData.left.name }}</text>
|
||||
<text class="fighter-votes">{{ detailData.leftVoteCount }} 票</text>
|
||||
</view>
|
||||
|
||||
<view class="battle-middle">
|
||||
<view class="vs-badge">VS</view>
|
||||
<text class="battle-middle-text">{{ leadText }}</text>
|
||||
</view>
|
||||
|
||||
<view class="fighter-card right" :class="{ selected: detailData.selfChooseSide === 'right' }">
|
||||
<view class="fighter-badge fighter-badge--right" v-if="detailData.selfChooseSide === 'right'">我支持</view>
|
||||
<image class="fighter-avatar" :src="detailData.right.avatar" mode="aspectFill" />
|
||||
<text class="fighter-name">{{ detailData.right.name }}</text>
|
||||
<text class="fighter-votes">{{ detailData.rightVoteCount }} 票</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="support-bar">
|
||||
<view class="support-fill left-fill" :style="{ width: `${detailData.leftPercent}%` }"></view>
|
||||
<view class="support-fill right-fill" :style="{ width: `${detailData.rightPercent}%` }"></view>
|
||||
</view>
|
||||
|
||||
<view class="support-row">
|
||||
<text class="support-text left-text">{{ detailData.leftPercent }}% 支持 {{ detailData.left.name }}</text>
|
||||
<text class="support-text right-text">{{ detailData.rightPercent }}% 支持 {{ detailData.right.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="state-card">
|
||||
<text class="state-title">暂时无法查看这场 PK</text>
|
||||
<text class="state-desc">这场对决的信息不存在或已失效,请返回列表重新进入。</text>
|
||||
<button class="state-btn" @tap="goBack">返回上一页</button>
|
||||
</view>
|
||||
|
||||
<CommentSection
|
||||
v-if="detailData"
|
||||
ref="commentSectionRef"
|
||||
variant="pk"
|
||||
:request-key="commentRequestKey"
|
||||
:visible="!!commentRequestKey"
|
||||
:allow-compose="true"
|
||||
compose-title="说说你的站队理由"
|
||||
:compose-desc="`支持 ${selectedItemLabel} 的观点,会更容易引发讨论`"
|
||||
compose-placeholder="写下你的 PK 观点..."
|
||||
page-name="pk_detail"
|
||||
pill-label="我站"
|
||||
:pill-value="selectedItemLabel"
|
||||
:pill-tone="selectedTone"
|
||||
loading-desc="看看大家如何评价这场对决"
|
||||
empty-title="还没有评论"
|
||||
empty-desc="来写下你的判断,成为这场 PK 的第一位评论官"
|
||||
footer-more-text="查看更多评论"
|
||||
:list-handler="fetchPkCommentList"
|
||||
:replies-handler="fetchPkCommentReplies"
|
||||
:publish-handler="publishPkComment"
|
||||
:normalize-comment="normalizePkComment"
|
||||
:like-handler="likeComment"
|
||||
@need-login="openLoginPopup('请先登录后再参与评论')"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<LoginPopup ref="loginPopupRef" @logind="handleLoginSuccess" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onPullDownRefresh, onReachBottom } from "@dcloudio/uni-app";
|
||||
import { getStatusBarHeight } from "@/utils/system";
|
||||
import { fetchCommentList, fetchCommentReplyList, publishComment, likeComment } from "@/api/pk";
|
||||
import { FILE_BASE_URL } from "@/utils/constants";
|
||||
import { formatDate } from "@/utils/date";
|
||||
import CommentSection from "@/components/CommentSection/CommentSection.vue";
|
||||
import LoginPopup from "@/components/LoginPopup/LoginPopup.vue";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
|
||||
const DETAIL_CACHE_PREFIX = "pk_detail_cache_";
|
||||
|
||||
const userStore = useUserStore();
|
||||
const statusBarHeight = ref(getStatusBarHeight() || 44);
|
||||
const loginPopupRef = ref(null);
|
||||
const commentSectionRef = ref(null);
|
||||
const detailData = ref(null);
|
||||
const pkId = ref("");
|
||||
|
||||
const isLoggedIn = computed(() => !!userStore.token || !!userStore.userInfo?.nickName);
|
||||
const selectedTone = computed(() => {
|
||||
if (!detailData.value?.selfChooseSide) return "";
|
||||
return detailData.value.selfChooseSide === "left" ? "left" : "right";
|
||||
});
|
||||
const selectedItemLabel = computed(() => {
|
||||
if (!detailData.value) return "";
|
||||
return detailData.value.selfChooseName || "未记录";
|
||||
});
|
||||
const commentRequestKey = computed(() => {
|
||||
if (!detailData.value?.id || !detailData.value?.selfChooseItemId) return "";
|
||||
return `${detailData.value.id}_${detailData.value.selfChooseItemId}`;
|
||||
});
|
||||
const leadText = computed(() => {
|
||||
if (!detailData.value) return "";
|
||||
const leftPercent = Number(detailData.value.leftPercent || 0);
|
||||
const rightPercent = Number(detailData.value.rightPercent || 0);
|
||||
const leader = leftPercent >= rightPercent ? detailData.value.left.name : detailData.value.right.name;
|
||||
const leaderPercent = leftPercent >= rightPercent ? leftPercent : rightPercent;
|
||||
return `${leader} 当前 ${leaderPercent}% 领先`;
|
||||
});
|
||||
const insightText = computed(() => {
|
||||
if (!detailData.value) return "这是一场你参与过的 PK 对决";
|
||||
const myChoice = detailData.value.selfChooseName || "你的选择";
|
||||
const myLeading =
|
||||
(detailData.value.selfChooseSide === "left" && detailData.value.leftPercent >= detailData.value.rightPercent) ||
|
||||
(detailData.value.selfChooseSide === "right" && detailData.value.rightPercent >= detailData.value.leftPercent);
|
||||
return myLeading
|
||||
? `你支持的 ${myChoice} 当前占优,来看看评论区大家的判断是否和你一致。`
|
||||
: `你支持的 ${myChoice} 暂时落后,看看评论区里有没有和你同样坚定的声音。`;
|
||||
});
|
||||
|
||||
const buildAvatarUrl = (avatar) => {
|
||||
if (!avatar) return "https://api.dicebear.com/7.x/avataaars/svg?seed=fallback";
|
||||
const normalizedAvatar = String(avatar).trim();
|
||||
return normalizedAvatar.startsWith("http") ? normalizedAvatar : `${FILE_BASE_URL}${normalizedAvatar}`;
|
||||
};
|
||||
|
||||
const clampPercent = (value) => {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return 0;
|
||||
return Math.max(0, Math.min(100, Math.round(number)));
|
||||
};
|
||||
|
||||
const normalizePercentPair = (item = {}) => {
|
||||
let leftPercent = clampPercent(item.leftPercent);
|
||||
let rightPercent = clampPercent(item.rightPercent);
|
||||
|
||||
if (!leftPercent && !rightPercent) {
|
||||
const leftVotes = Number(item.leftVoteCount || 0);
|
||||
const rightVotes = Number(item.rightVoteCount || 0);
|
||||
const totalVotes = leftVotes + rightVotes;
|
||||
if (totalVotes > 0) {
|
||||
leftPercent = Math.round((leftVotes / totalVotes) * 100);
|
||||
rightPercent = 100 - leftPercent;
|
||||
} else {
|
||||
leftPercent = 50;
|
||||
rightPercent = 50;
|
||||
}
|
||||
} else if (!leftPercent) {
|
||||
leftPercent = 100 - rightPercent;
|
||||
} else if (!rightPercent) {
|
||||
rightPercent = 100 - leftPercent;
|
||||
}
|
||||
|
||||
const total = leftPercent + rightPercent;
|
||||
if (total && total !== 100) {
|
||||
leftPercent = Math.round((leftPercent / total) * 100);
|
||||
rightPercent = 100 - leftPercent;
|
||||
}
|
||||
|
||||
return { leftPercent, rightPercent };
|
||||
};
|
||||
|
||||
const normalizeDetail = (item = {}) => {
|
||||
const { leftPercent, rightPercent } = normalizePercentPair(item);
|
||||
const selfChooseSide =
|
||||
item.selfChooseSide ||
|
||||
(item.selfChooseItemId && item.selfChooseItemId === item.leftItemId
|
||||
? "left"
|
||||
: item.selfChooseItemId && item.selfChooseItemId === item.rightItemId
|
||||
? "right"
|
||||
: "");
|
||||
const selfChooseName =
|
||||
selfChooseSide === "right"
|
||||
? (item.rightItemName || "右侧对象")
|
||||
: selfChooseSide === "left"
|
||||
? (item.leftItemName || "左侧对象")
|
||||
: "未记录";
|
||||
|
||||
return {
|
||||
id: item.id || "",
|
||||
topicId: item.topicId || "",
|
||||
topicTitle: item.topicTitle || "未知话题",
|
||||
voteCount: Number(item.voteCount || 0),
|
||||
leftVoteCount: Number(item.leftVoteCount || 0),
|
||||
rightVoteCount: Number(item.rightVoteCount || 0),
|
||||
selfChooseItemId: item.selfChooseItemId || "",
|
||||
selfChooseSide,
|
||||
selfChooseName,
|
||||
leftPercent,
|
||||
rightPercent,
|
||||
votedAt: item.votedAt || "",
|
||||
votedAtText: formatDate(item.votedAt, "YYYY-MM-DD HH:mm") || "刚刚参与",
|
||||
left: {
|
||||
id: item.leftItemId || "",
|
||||
name: item.leftItemName || "左侧对象",
|
||||
avatar: buildAvatarUrl(item.leftItemAvatar),
|
||||
},
|
||||
right: {
|
||||
id: item.rightItemId || "",
|
||||
name: item.rightItemName || "右侧对象",
|
||||
avatar: buildAvatarUrl(item.rightItemAvatar),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const resolveChoiceMeta = (chooseItemId) => {
|
||||
if (!chooseItemId || !detailData.value) {
|
||||
return { choiceLabel: "", choiceTone: "" };
|
||||
}
|
||||
if (chooseItemId === detailData.value.left.id) {
|
||||
return { choiceLabel: `支持 ${detailData.value.left.name}`, choiceTone: "left" };
|
||||
}
|
||||
if (chooseItemId === detailData.value.right.id) {
|
||||
return { choiceLabel: `支持 ${detailData.value.right.name}`, choiceTone: "right" };
|
||||
}
|
||||
return { choiceLabel: "已站队", choiceTone: "" };
|
||||
};
|
||||
|
||||
const normalizePkComment = (item = {}, rootComment = null) => {
|
||||
const user = item.user || {};
|
||||
const replyToUser = item.replyToUser || {};
|
||||
const choiceMeta = resolveChoiceMeta(item.chooseItemId);
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
rootCommentId: item.rootCommentId || rootComment?.id || null,
|
||||
parentCommentId: item.parentCommentId || rootComment?.id || null,
|
||||
replyToCommentId: item.replyToCommentId || null,
|
||||
name: user.nickname || "匿名用户",
|
||||
avatar: buildAvatarUrl(user.avatar),
|
||||
time: formatDate(item.createdAt) || "刚刚",
|
||||
content: item.content || "",
|
||||
likes: Number(item.likeCount || 0),
|
||||
isLiked: !!item.isLiked,
|
||||
userAction: "",
|
||||
replyCount: Number(item.replyCount || 0),
|
||||
replyToName: replyToUser.nickname || "",
|
||||
choiceLabel: choiceMeta.choiceLabel,
|
||||
choiceTone: choiceMeta.choiceTone,
|
||||
chooseItemId: item.chooseItemId || "",
|
||||
repliesLoading: false,
|
||||
repliesLoaded: false,
|
||||
repliesHasMore: false,
|
||||
repliesPage: 1,
|
||||
replies: [],
|
||||
};
|
||||
};
|
||||
|
||||
const openLoginPopup = (message = "请先登录后再操作") => {
|
||||
loginPopupRef.value?.open();
|
||||
if (message) {
|
||||
uni.showToast({ title: message, icon: "none" });
|
||||
}
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
uni.navigateBack();
|
||||
};
|
||||
|
||||
const goToPkStation = () => {
|
||||
uni.switchTab({
|
||||
url: "/pages/pk/index",
|
||||
});
|
||||
};
|
||||
|
||||
const fetchPkCommentList = ({ page, orderBy }) => {
|
||||
return fetchCommentList({
|
||||
pkId: pkId.value,
|
||||
page,
|
||||
orderBy,
|
||||
});
|
||||
};
|
||||
|
||||
const fetchPkCommentReplies = ({ rootCommentId, page }) => {
|
||||
return fetchCommentReplyList({
|
||||
pkId: pkId.value,
|
||||
rootCommentId,
|
||||
page,
|
||||
});
|
||||
};
|
||||
|
||||
const publishPkComment = ({ content, replyTarget, mode }) => {
|
||||
return publishComment({
|
||||
pkId: pkId.value,
|
||||
chooseItemId: detailData.value?.selfChooseItemId,
|
||||
content,
|
||||
parentCommentId: mode === "reply" ? replyTarget?.parentCommentId : undefined,
|
||||
replyToCommentId: mode === "reply" ? replyTarget?.replyToCommentId : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const hydrateDetail = (options = {}) => {
|
||||
const cacheKey = options.cacheKey || `${DETAIL_CACHE_PREFIX}${options.pkId || ""}`;
|
||||
const cached = cacheKey ? uni.getStorageSync(cacheKey) : null;
|
||||
if (cached && typeof cached === "object") {
|
||||
detailData.value = normalizeDetail(cached);
|
||||
pkId.value = detailData.value.id;
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.payload) {
|
||||
try {
|
||||
const parsed = JSON.parse(decodeURIComponent(options.payload));
|
||||
detailData.value = normalizeDetail(parsed);
|
||||
pkId.value = detailData.value.id;
|
||||
} catch (error) {
|
||||
detailData.value = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoginSuccess = async () => {
|
||||
await commentSectionRef.value?.refreshComments?.();
|
||||
};
|
||||
|
||||
onLoad((options) => {
|
||||
pkId.value = options.pkId || "";
|
||||
hydrateDetail(options);
|
||||
});
|
||||
|
||||
onPullDownRefresh(async () => {
|
||||
await commentSectionRef.value?.refreshComments?.();
|
||||
uni.stopPullDownRefresh();
|
||||
});
|
||||
|
||||
onReachBottom(() => {
|
||||
commentSectionRef.value?.loadMoreComments?.();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-container {
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(111, 90, 255, 0.14), transparent 32%),
|
||||
linear-gradient(180deg, #f7f8ff 0%, #f7f8fc 44%, #ffffff 100%);
|
||||
}
|
||||
|
||||
.nav-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
background: rgba(247, 248, 255, 0.88);
|
||||
backdrop-filter: blur(24rpx);
|
||||
}
|
||||
|
||||
.nav-content {
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 28rpx 0 24rpx;
|
||||
}
|
||||
|
||||
.nav-left,
|
||||
.nav-right {
|
||||
min-width: 84rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-right {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.back-icon {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23202433' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='19' y1='12' x2='5' y2='12'%3E%3C/line%3E%3Cpolyline points='12 19 5 12 12 5'%3E%3C/polyline%3E%3C/svg%3E");
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 800;
|
||||
color: #202433;
|
||||
}
|
||||
|
||||
.nav-right-text {
|
||||
font-size: 24rpx;
|
||||
color: #5c43f5;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.content-wrap {
|
||||
padding: 24rpx 32rpx 40rpx;
|
||||
}
|
||||
|
||||
.hero-card,
|
||||
.battle-card,
|
||||
.state-card {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 2rpx solid rgba(229, 232, 246, 0.92);
|
||||
border-radius: 32rpx;
|
||||
box-shadow: 0 18rpx 42rpx rgba(87, 92, 145, 0.08);
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
padding: 32rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.hero-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 44rpx;
|
||||
padding: 0 18rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(92, 67, 245, 0.1);
|
||||
color: #5c43f5;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
margin-bottom: 18rpx;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
display: block;
|
||||
font-size: 40rpx;
|
||||
font-weight: 800;
|
||||
color: #1f2432;
|
||||
margin-bottom: 12rpx;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.hero-desc {
|
||||
display: block;
|
||||
font-size: 25rpx;
|
||||
color: #7b8191;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.summary-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.summary-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
padding: 12rpx 18rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(92, 67, 245, 0.1);
|
||||
}
|
||||
|
||||
.summary-pill--right {
|
||||
background: rgba(255, 141, 95, 0.12);
|
||||
}
|
||||
|
||||
.summary-pill-label {
|
||||
font-size: 22rpx;
|
||||
color: #8a90a0;
|
||||
}
|
||||
|
||||
.summary-pill-value {
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
color: #4d44f1;
|
||||
}
|
||||
|
||||
.summary-pill--right .summary-pill-value {
|
||||
color: #ff7a45;
|
||||
}
|
||||
|
||||
.summary-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.summary-meta-item {
|
||||
font-size: 22rpx;
|
||||
color: #8a90a0;
|
||||
}
|
||||
|
||||
.summary-dot {
|
||||
width: 8rpx;
|
||||
height: 8rpx;
|
||||
border-radius: 50%;
|
||||
background: #d1d5db;
|
||||
}
|
||||
|
||||
.battle-card {
|
||||
padding: 28rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.battle-stage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12rpx;
|
||||
margin-bottom: 22rpx;
|
||||
}
|
||||
|
||||
.fighter-card {
|
||||
position: relative;
|
||||
width: 224rpx;
|
||||
min-height: 276rpx;
|
||||
border-radius: 28rpx;
|
||||
padding: 30rpx 18rpx 24rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(180deg, #fafbff 0%, #ffffff 100%);
|
||||
border: 2rpx solid transparent;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.fighter-card.left {
|
||||
border-color: rgba(92, 67, 245, 0.18);
|
||||
}
|
||||
|
||||
.fighter-card.right {
|
||||
border-color: rgba(255, 153, 102, 0.2);
|
||||
}
|
||||
|
||||
.fighter-card.selected {
|
||||
transform: translateY(-4rpx);
|
||||
box-shadow: 0 14rpx 28rpx rgba(92, 67, 245, 0.12);
|
||||
}
|
||||
|
||||
.fighter-badge {
|
||||
position: absolute;
|
||||
top: 16rpx;
|
||||
left: 16rpx;
|
||||
padding: 8rpx 14rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(92, 67, 245, 0.12);
|
||||
color: #5c43f5;
|
||||
font-size: 20rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.fighter-badge--right {
|
||||
background: rgba(255, 141, 95, 0.14);
|
||||
color: #ff7a45;
|
||||
}
|
||||
|
||||
.fighter-avatar {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 50%;
|
||||
border: 6rpx solid #fff;
|
||||
box-shadow: 0 8rpx 20rpx rgba(0, 0, 0, 0.08);
|
||||
margin-bottom: 18rpx;
|
||||
}
|
||||
|
||||
.fighter-name {
|
||||
font-size: 30rpx;
|
||||
color: #1f2432;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.fighter-votes {
|
||||
font-size: 22rpx;
|
||||
color: #858b9b;
|
||||
}
|
||||
|
||||
.battle-middle {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vs-badge {
|
||||
width: 90rpx;
|
||||
height: 90rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #6a5bff, #4d44f1);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28rpx;
|
||||
font-weight: 900;
|
||||
box-shadow: 0 16rpx 30rpx rgba(92, 67, 245, 0.24);
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.battle-middle-text {
|
||||
font-size: 22rpx;
|
||||
color: #8a90a0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.support-bar {
|
||||
height: 18rpx;
|
||||
border-radius: 999rpx;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
background: #eceff8;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.support-fill {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.left-fill {
|
||||
background: linear-gradient(90deg, #6557ff, #4d44f1);
|
||||
}
|
||||
|
||||
.right-fill {
|
||||
background: linear-gradient(90deg, #ffb15f, #ff8d5f);
|
||||
}
|
||||
|
||||
.support-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.support-text {
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.left-text {
|
||||
color: #5c43f5;
|
||||
}
|
||||
|
||||
.right-text {
|
||||
color: #ff8d5f;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.state-card {
|
||||
padding: 64rpx 40rpx;
|
||||
text-align: center;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.state-title {
|
||||
display: block;
|
||||
font-size: 32rpx;
|
||||
color: #232734;
|
||||
font-weight: 800;
|
||||
margin-bottom: 14rpx;
|
||||
}
|
||||
|
||||
.state-desc {
|
||||
display: block;
|
||||
font-size: 25rpx;
|
||||
color: #8a90a0;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
|
||||
.state-btn {
|
||||
width: 240rpx;
|
||||
height: 84rpx;
|
||||
line-height: 84rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #111827;
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.state-btn::after {
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
782
pages/pk/index.vue
Normal file
@@ -0,0 +1,782 @@
|
||||
<template>
|
||||
<view class="page-container">
|
||||
<view class="nav-bar" :style="{ paddingTop: statusBarHeight + 'px' }">
|
||||
<view class="nav-content">
|
||||
<text class="nav-title">PK站</text>
|
||||
<view class="nav-action" @tap="refreshPool">
|
||||
<text class="nav-action-text">刷新</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view :style="{ height: statusBarHeight + 44 + 'px' }"></view>
|
||||
|
||||
<view class="content-wrap">
|
||||
<view class="hero-card">
|
||||
<view class="hero-badge">Battle Arena</view>
|
||||
<text class="hero-title">随机PK</text>
|
||||
<text class="hero-desc">系统会从当前可用话题中随机生成对局,只给你还没参与过的 PK 题。</text>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="state-card">
|
||||
<text class="state-title">正在生成 PK 对局...</text>
|
||||
<text class="state-desc">马上就给你安排一场新的神仙打架</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="currentPair" class="pk-card">
|
||||
<view class="topic-pill">
|
||||
<text class="topic-pill-label">当前话题</text>
|
||||
<text class="topic-pill-title">{{ currentPair.topicTitle }}</text>
|
||||
</view>
|
||||
|
||||
<view class="battle-stage">
|
||||
<view class="fighter-card left" :class="{ chosen: selectedSide === 'left' }">
|
||||
<image class="fighter-avatar" :src="currentPair.left.avatar" mode="aspectFill" />
|
||||
<text class="fighter-name">{{ currentPair.left.name }}</text>
|
||||
<!-- <text class="fighter-meta">{{ currentPair.left.scoreText }}</text> -->
|
||||
</view>
|
||||
|
||||
<view class="battle-middle">
|
||||
<view class="vs-badge">VS</view>
|
||||
<text class="battle-middle-text">你更支持谁?</text>
|
||||
</view>
|
||||
|
||||
<view class="fighter-card right" :class="{ chosen: selectedSide === 'right' }">
|
||||
<image class="fighter-avatar" :src="currentPair.right.avatar" mode="aspectFill" />
|
||||
<text class="fighter-name">{{ currentPair.right.name }}</text>
|
||||
<!-- <text class="fighter-meta">{{ currentPair.right.scoreText }}</text> -->
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="hasVoted" class="result-card">
|
||||
<view class="result-header">
|
||||
<text class="result-title">PK 结果</text>
|
||||
<text class="result-choice">你的选择:{{ selectedSide === 'left' ? currentPair.left.name : currentPair.right.name }}</text>
|
||||
</view>
|
||||
|
||||
<view class="support-bar">
|
||||
<view class="support-fill left-fill" :style="{ width: `${supportStats.leftPercent}%` }"></view>
|
||||
<view class="support-fill right-fill" :style="{ width: `${supportStats.rightPercent}%` }"></view>
|
||||
</view>
|
||||
|
||||
<view class="support-text">
|
||||
<text class="left-text">{{ supportStats.leftPercent }}% 支持 {{ currentPair.left.name }}</text>
|
||||
<text class="right-text">{{ supportStats.rightPercent }}% 支持 {{ currentPair.right.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="action-row">
|
||||
<button class="vote-btn left-btn" :disabled="voting" @tap="handleVote('left')">支持 {{ currentPair.left.name }}</button>
|
||||
<button class="vote-btn right-btn" :disabled="voting" @tap="handleVote('right')">支持 {{ currentPair.right.name }}</button>
|
||||
</view>
|
||||
|
||||
<view class="footer-row">
|
||||
<!-- <view class="battle-tip-card">
|
||||
<image class="battle-tip-icon" src="/static/images/icon/robot.png" mode="aspectFit"></image>
|
||||
<text class="battle-tip-text">{{ battleInsight }}</text>
|
||||
</view> -->
|
||||
<button class="next-btn" :disabled="loading" @tap="loadNextPair">下一条 PK</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<CommentSection
|
||||
v-if="currentPair && hasVoted"
|
||||
ref="commentSectionRef"
|
||||
variant="pk"
|
||||
:request-key="commentRequestKey"
|
||||
:visible="hasVoted"
|
||||
:allow-compose="true"
|
||||
compose-title="说说你的理由"
|
||||
:compose-desc="`支持 ${selectedItemLabel} 的观点,会更容易引发讨论`"
|
||||
compose-placeholder="写下你的 PK 观点..."
|
||||
page-name="pk_station"
|
||||
pill-label="我站"
|
||||
:pill-value="selectedItemLabel"
|
||||
:pill-tone="selectedTone"
|
||||
loading-desc="看看大家都站哪边"
|
||||
empty-title="还没有人发言"
|
||||
empty-desc="你可以成为这场 PK 的第一位评论官"
|
||||
footer-more-text="查看更多评论"
|
||||
:list-handler="fetchPkCommentList"
|
||||
:replies-handler="fetchPkCommentReplies"
|
||||
:publish-handler="publishPkComment"
|
||||
:normalize-comment="normalizePkComment"
|
||||
:like-handler="likeComment"
|
||||
@need-login="openLoginPopup('请先登录后再参与评论')"
|
||||
@published="handleCommentPublished"
|
||||
/>
|
||||
|
||||
<view v-else-if="!currentPair" class="state-card">
|
||||
<text class="state-title">暂时没有新的 PK 对局</text>
|
||||
<text class="state-desc">当前没有可参与的随机对局,稍后再来看看有没有新的组合。</text>
|
||||
<button class="state-btn" @tap="loadNextPair">重新获取</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<LoginPopup ref="loginPopupRef" @logind="handleLoginSuccess" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onPullDownRefresh, onShareAppMessage, onShareTimeline } from "@dcloudio/uni-app";
|
||||
import { getStatusBarHeight } from "@/utils/system";
|
||||
import { fetchRandomTopicList, vote, fetchCommentList, fetchCommentReplyList, publishComment, likeComment } from "@/api/pk";
|
||||
import { FILE_BASE_URL } from "@/utils/constants";
|
||||
import { formatDate } from "@/utils/date";
|
||||
import CommentSection from "@/components/CommentSection/CommentSection.vue";
|
||||
import LoginPopup from "@/components/LoginPopup/LoginPopup.vue";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
import { getShareReward } from "@/api/system";
|
||||
import { getShareToken } from "@/utils/common";
|
||||
|
||||
const userStore = useUserStore();
|
||||
const statusBarHeight = ref(getStatusBarHeight() || 44);
|
||||
const loginPopupRef = ref(null);
|
||||
const commentSectionRef = ref(null);
|
||||
const loading = ref(false);
|
||||
const voting = ref(false);
|
||||
const currentPair = ref(null);
|
||||
const selectedSide = ref("");
|
||||
const voteStats = ref({ leftPercent: 50, rightPercent: 50 });
|
||||
|
||||
const isLoggedIn = computed(() => !!userStore.token || !!userStore.userInfo?.nickName);
|
||||
const hasVoted = computed(() => !!selectedSide.value);
|
||||
const selectedItemId = computed(() => {
|
||||
if (!currentPair.value || !selectedSide.value) return "";
|
||||
return selectedSide.value === "left" ? currentPair.value.left.id : currentPair.value.right.id;
|
||||
});
|
||||
const selectedItemLabel = computed(() => {
|
||||
if (!currentPair.value || !selectedSide.value) return "";
|
||||
return selectedSide.value === "left" ? currentPair.value.left.name : currentPair.value.right.name;
|
||||
});
|
||||
const selectedTone = computed(() => {
|
||||
if (!selectedSide.value) return "";
|
||||
return selectedSide.value === "left" ? "left" : "right";
|
||||
});
|
||||
const commentRequestKey = computed(() => {
|
||||
if (!currentPair.value?.id || !selectedItemId.value) return "";
|
||||
return `${currentPair.value.id}_${selectedItemId.value}`;
|
||||
});
|
||||
|
||||
const buildAvatarUrl = (avatar) => {
|
||||
if (!avatar) return "https://api.dicebear.com/7.x/avataaars/svg?seed=fallback";
|
||||
const normalizedAvatar = String(avatar).trim();
|
||||
return normalizedAvatar.startsWith("http") ? normalizedAvatar : `${FILE_BASE_URL}${normalizedAvatar}`;
|
||||
};
|
||||
|
||||
const resolveChoiceMeta = (chooseItemId) => {
|
||||
if (!chooseItemId || !currentPair.value) {
|
||||
return { choiceLabel: "", choiceTone: "" };
|
||||
}
|
||||
if (chooseItemId === currentPair.value.left?.id) {
|
||||
return { choiceLabel: `支持 ${currentPair.value.left.name}`, choiceTone: "left" };
|
||||
}
|
||||
if (chooseItemId === currentPair.value.right?.id) {
|
||||
return { choiceLabel: `支持 ${currentPair.value.right.name}`, choiceTone: "right" };
|
||||
}
|
||||
return { choiceLabel: "已站队", choiceTone: "" };
|
||||
};
|
||||
|
||||
const normalizePkComment = (item = {}, rootComment = null) => {
|
||||
const user = item.user || {};
|
||||
const replyToUser = item.replyToUser || {};
|
||||
const choiceMeta = resolveChoiceMeta(item.chooseItemId);
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
rootCommentId: item.rootCommentId || rootComment?.id || null,
|
||||
parentCommentId: item.parentCommentId || rootComment?.id || null,
|
||||
replyToCommentId: item.replyToCommentId || null,
|
||||
name: user.nickname || "匿名用户",
|
||||
avatar: buildAvatarUrl(user.avatar),
|
||||
time: formatDate(item.createdAt) || "刚刚",
|
||||
content: item.content || "",
|
||||
likes: Number(item.likeCount || 0),
|
||||
isLiked: !!item.isLiked,
|
||||
userAction: "",
|
||||
replyCount: Number(item.replyCount || 0),
|
||||
replyToName: replyToUser.nickname || "",
|
||||
choiceLabel: choiceMeta.choiceLabel,
|
||||
choiceTone: choiceMeta.choiceTone,
|
||||
chooseItemId: item.chooseItemId || "",
|
||||
repliesLoading: false,
|
||||
repliesLoaded: false,
|
||||
repliesHasMore: false,
|
||||
repliesPage: 1,
|
||||
replies: [],
|
||||
};
|
||||
};
|
||||
|
||||
const fetchPkCommentList = ({ page, orderBy }) => {
|
||||
return fetchCommentList({
|
||||
pkId: currentPair.value?.id,
|
||||
page,
|
||||
orderBy,
|
||||
});
|
||||
};
|
||||
|
||||
const fetchPkCommentReplies = ({ rootCommentId, page }) => {
|
||||
return fetchCommentReplyList({
|
||||
pkId: currentPair.value?.id,
|
||||
rootCommentId,
|
||||
page,
|
||||
});
|
||||
};
|
||||
|
||||
const publishPkComment = ({ content, replyTarget, mode }) => {
|
||||
return publishComment({
|
||||
pkId: currentPair.value?.id,
|
||||
chooseItemId: selectedItemId.value,
|
||||
content,
|
||||
parentCommentId: mode === "reply" ? replyTarget?.parentCommentId : undefined,
|
||||
replyToCommentId: mode === "reply" ? replyTarget?.replyToCommentId : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCommentPublished = () => {};
|
||||
|
||||
const normalizePercent = (value) => {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return null;
|
||||
return number <= 1 ? Math.round(number * 100) : Math.round(number);
|
||||
};
|
||||
|
||||
const normalizeStats = (data = {}) => {
|
||||
const source = data?.data || data || {};
|
||||
const leftRaw =
|
||||
source.leftPercent ??
|
||||
source.leftSupportRate ??
|
||||
source.leftItemSupportRate ??
|
||||
source.leftRate ??
|
||||
source.leftItemRate ??
|
||||
source.leftRatio ??
|
||||
source.leftItemRatio ??
|
||||
source.leftSupportPercent ??
|
||||
source.leftItemSupportPercent ??
|
||||
source.leftItemPercent;
|
||||
const rightRaw =
|
||||
source.rightPercent ??
|
||||
source.rightSupportRate ??
|
||||
source.rightItemSupportRate ??
|
||||
source.rightRate ??
|
||||
source.rightItemRate ??
|
||||
source.rightRatio ??
|
||||
source.rightItemRatio ??
|
||||
source.rightSupportPercent ??
|
||||
source.rightItemSupportPercent ??
|
||||
source.rightItemPercent;
|
||||
|
||||
let leftPercent = normalizePercent(leftRaw);
|
||||
let rightPercent = normalizePercent(rightRaw);
|
||||
|
||||
if (leftPercent === null && rightPercent === null) {
|
||||
const leftCount = Number(source.leftCount ?? source.leftVotes ?? source.leftSupportCount ?? source.leftItemSupportCount ?? 0);
|
||||
const rightCount = Number(source.rightCount ?? source.rightVotes ?? source.rightSupportCount ?? source.rightItemSupportCount ?? 0);
|
||||
const total = leftCount + rightCount;
|
||||
if (total > 0) {
|
||||
leftPercent = Math.round((leftCount / total) * 100);
|
||||
rightPercent = 100 - leftPercent;
|
||||
}
|
||||
} else if (leftPercent === null) {
|
||||
leftPercent = 100 - rightPercent;
|
||||
} else if (rightPercent === null) {
|
||||
rightPercent = 100 - leftPercent;
|
||||
}
|
||||
|
||||
if (leftPercent === null || rightPercent === null) {
|
||||
return { leftPercent: 50, rightPercent: 50 };
|
||||
}
|
||||
|
||||
leftPercent = Math.max(0, Math.min(100, leftPercent));
|
||||
rightPercent = Math.max(0, Math.min(100, rightPercent));
|
||||
const totalPercent = leftPercent + rightPercent;
|
||||
if (totalPercent && totalPercent !== 100) {
|
||||
leftPercent = Math.round((leftPercent / totalPercent) * 100);
|
||||
rightPercent = 100 - leftPercent;
|
||||
}
|
||||
|
||||
return { leftPercent, rightPercent };
|
||||
};
|
||||
|
||||
const supportStats = computed(() => voteStats.value);
|
||||
|
||||
const battleInsight = computed(() => {
|
||||
if (!currentPair.value) return "随机生成新对局中...";
|
||||
const left = currentPair.value.left;
|
||||
const right = currentPair.value.right;
|
||||
if (!hasVoted.value) {
|
||||
return `${left.name} 和 ${right.name} 正在同台 PK,先站队再看大家的支持率。`;
|
||||
}
|
||||
const leader = supportStats.value.leftPercent >= supportStats.value.rightPercent ? left : right;
|
||||
const percent = leader.id === left.id ? supportStats.value.leftPercent : supportStats.value.rightPercent;
|
||||
return `${leader.name} 当前获得 ${percent}% 支持,下一条 PK 可能又是完全不同的风向。`;
|
||||
});
|
||||
|
||||
const openLoginPopup = (message = "请先登录后再操作") => {
|
||||
loginPopupRef.value?.open();
|
||||
if (message) {
|
||||
uni.showToast({ title: message, icon: "none" });
|
||||
}
|
||||
};
|
||||
|
||||
const normalizePair = (item) => {
|
||||
if (!item?.id) return null;
|
||||
return {
|
||||
id: item.id,
|
||||
topicId: item.topicId,
|
||||
topicTitle: item.topicTitle || "未知话题",
|
||||
left: {
|
||||
id: item.leftItemId,
|
||||
name: item.leftItemName || "未知对象",
|
||||
avatar: buildAvatarUrl(item.leftItemAvatar),
|
||||
scoreText: "等待站队",
|
||||
},
|
||||
right: {
|
||||
id: item.rightItemId,
|
||||
name: item.rightItemName || "未知对象",
|
||||
avatar: buildAvatarUrl(item.rightItemAvatar),
|
||||
scoreText: "等待站队",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const loadRandomPair = async () => {
|
||||
const pair = await fetchRandomTopicList();
|
||||
currentPair.value = normalizePair(pair);
|
||||
selectedSide.value = "";
|
||||
voteStats.value = normalizeStats(pair);
|
||||
};
|
||||
|
||||
const loadNextPair = async () => {
|
||||
if (loading.value) return;
|
||||
try {
|
||||
loading.value = true;
|
||||
await loadRandomPair();
|
||||
} catch (error) {
|
||||
currentPair.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
uni.stopPullDownRefresh();
|
||||
}
|
||||
};
|
||||
|
||||
const handleVote = async (side) => {
|
||||
if (!isLoggedIn.value) {
|
||||
openLoginPopup("请先登录后再参与 PK");
|
||||
return;
|
||||
}
|
||||
if (!currentPair.value || hasVoted.value || voting.value) return;
|
||||
const itemId = side === "left" ? currentPair.value.left.id : currentPair.value.right.id;
|
||||
if (!itemId) return;
|
||||
|
||||
try {
|
||||
voting.value = true;
|
||||
const result = await vote({
|
||||
pkId: currentPair.value.id,
|
||||
itemId,
|
||||
});
|
||||
selectedSide.value = side;
|
||||
voteStats.value = normalizeStats(result);
|
||||
} finally {
|
||||
voting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const refreshPool = () => {
|
||||
loadNextPair();
|
||||
};
|
||||
|
||||
const handleLoginSuccess = async () => {
|
||||
if (hasVoted.value) {
|
||||
await commentSectionRef.value?.refreshComments?.();
|
||||
}
|
||||
};
|
||||
|
||||
onLoad(() => {
|
||||
loadNextPair();
|
||||
});
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
loadNextPair();
|
||||
});
|
||||
|
||||
onShareAppMessage(async () => {
|
||||
const shareToken = await getShareToken("pk_station");
|
||||
getShareReward({ scene: "pk_station" });
|
||||
return {
|
||||
title: currentPair.value
|
||||
? `${currentPair.value.left.name} VS ${currentPair.value.right.name},你站谁?`
|
||||
: "夯拉评分 PK站,来看看下一场神仙打架",
|
||||
path: `/pages/pk/index?shareToken=${shareToken}`,
|
||||
};
|
||||
});
|
||||
|
||||
onShareTimeline(async () => {
|
||||
const shareToken = await getShareToken("pk_station");
|
||||
getShareReward({ scene: "pk_station_timeline" });
|
||||
return {
|
||||
title: currentPair.value
|
||||
? `${currentPair.value.left.name} 和 ${currentPair.value.right.name} 正在 PK,快来站队`
|
||||
: "夯拉评分 PK站已开启,来刷下一场对决",
|
||||
query: `shareToken=${shareToken}`,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-container {
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(111, 90, 255, 0.14), transparent 32%),
|
||||
linear-gradient(180deg, #f7f8ff 0%, #f7f8fc 44%, #ffffff 100%);
|
||||
}
|
||||
|
||||
.nav-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
background: rgba(247, 248, 255, 0.88);
|
||||
backdrop-filter: blur(24rpx);
|
||||
}
|
||||
|
||||
.nav-content {
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 28rpx 0 32rpx;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 800;
|
||||
color: #202433;
|
||||
}
|
||||
|
||||
.nav-action {
|
||||
min-width: 72rpx;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.nav-action-text {
|
||||
font-size: 26rpx;
|
||||
color: #5c43f5;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.content-wrap {
|
||||
padding: 24rpx 32rpx 40rpx;
|
||||
}
|
||||
|
||||
.hero-card,
|
||||
.pk-card,
|
||||
.state-card {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 2rpx solid rgba(229, 232, 246, 0.92);
|
||||
border-radius: 32rpx;
|
||||
box-shadow: 0 18rpx 42rpx rgba(87, 92, 145, 0.08);
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
padding: 32rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.hero-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 44rpx;
|
||||
padding: 0 18rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(92, 67, 245, 0.1);
|
||||
color: #5c43f5;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
margin-bottom: 18rpx;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
display: block;
|
||||
font-size: 42rpx;
|
||||
font-weight: 800;
|
||||
color: #1f2432;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.hero-desc {
|
||||
font-size: 25rpx;
|
||||
color: #7b8191;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.pk-card {
|
||||
padding: 30rpx;
|
||||
}
|
||||
|
||||
.topic-pill {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 22rpx 24rpx;
|
||||
border-radius: 24rpx;
|
||||
background: linear-gradient(135deg, rgba(92, 67, 245, 0.08), rgba(92, 67, 245, 0.03));
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
|
||||
.topic-pill-label {
|
||||
font-size: 22rpx;
|
||||
color: #8a90a0;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.topic-pill-title {
|
||||
font-size: 30rpx;
|
||||
color: #222736;
|
||||
font-weight: 800;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.battle-stage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
|
||||
.fighter-card {
|
||||
width: 230rpx;
|
||||
min-height: 304rpx;
|
||||
border-radius: 28rpx;
|
||||
padding: 28rpx 18rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(180deg, #fafbff 0%, #ffffff 100%);
|
||||
box-sizing: border-box;
|
||||
border: 2rpx solid transparent;
|
||||
}
|
||||
|
||||
.fighter-card.left {
|
||||
border-color: rgba(92, 67, 245, 0.18);
|
||||
}
|
||||
|
||||
.fighter-card.right {
|
||||
border-color: rgba(255, 153, 102, 0.18);
|
||||
}
|
||||
|
||||
.fighter-card.chosen {
|
||||
transform: translateY(-4rpx);
|
||||
box-shadow: 0 14rpx 28rpx rgba(92, 67, 245, 0.14);
|
||||
}
|
||||
|
||||
.fighter-avatar {
|
||||
width: 128rpx;
|
||||
height: 128rpx;
|
||||
border-radius: 50%;
|
||||
border: 6rpx solid #fff;
|
||||
box-shadow: 0 8rpx 20rpx rgba(0, 0, 0, 0.08);
|
||||
margin-bottom: 18rpx;
|
||||
}
|
||||
|
||||
.fighter-name {
|
||||
font-size: 30rpx;
|
||||
color: #1f2432;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.fighter-meta {
|
||||
font-size: 24rpx;
|
||||
color: #858b9b;
|
||||
}
|
||||
|
||||
.battle-middle {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.vs-badge {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #6a5bff, #4d44f1);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 30rpx;
|
||||
font-weight: 900;
|
||||
box-shadow: 0 16rpx 30rpx rgba(92, 67, 245, 0.24);
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.battle-middle-text {
|
||||
font-size: 22rpx;
|
||||
color: #8a90a0;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
gap: 18rpx;
|
||||
margin-bottom: 22rpx;
|
||||
}
|
||||
|
||||
.vote-btn,
|
||||
.next-btn,
|
||||
.state-btn {
|
||||
border-radius: 999rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.vote-btn::after,
|
||||
.next-btn::after,
|
||||
.state-btn::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.vote-btn {
|
||||
flex: 1;
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
}
|
||||
|
||||
.left-btn {
|
||||
background: linear-gradient(135deg, #6a5bff, #4d44f1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.right-btn {
|
||||
background: linear-gradient(135deg, #ffb25c, #ff8e63);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.result-card {
|
||||
margin-bottom: 22rpx;
|
||||
padding: 24rpx;
|
||||
border-radius: 24rpx;
|
||||
background: #f8f9ff;
|
||||
}
|
||||
|
||||
.result-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
font-size: 28rpx;
|
||||
color: #222736;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.result-choice {
|
||||
font-size: 24rpx;
|
||||
color: #5c43f5;
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.support-bar {
|
||||
height: 18rpx;
|
||||
border-radius: 999rpx;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
background: #eceff8;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.support-fill {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.left-fill {
|
||||
background: linear-gradient(90deg, #6557ff, #4d44f1);
|
||||
}
|
||||
|
||||
.right-fill {
|
||||
background: linear-gradient(90deg, #ffb15f, #ff8d5f);
|
||||
}
|
||||
|
||||
.support-text {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.left-text {
|
||||
color: #5c43f5;
|
||||
}
|
||||
|
||||
.right-text {
|
||||
color: #ff8d5f;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.footer-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.battle-tip-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 22rpx 24rpx;
|
||||
border-radius: 24rpx;
|
||||
background: linear-gradient(135deg, rgba(92, 67, 245, 0.06), rgba(92, 67, 245, 0.02));
|
||||
}
|
||||
|
||||
.battle-tip-icon {
|
||||
width: 30rpx;
|
||||
height: 30rpx;
|
||||
margin-right: 12rpx;
|
||||
margin-top: 4rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.battle-tip-text {
|
||||
font-size: 24rpx;
|
||||
color: #596071;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.next-btn,
|
||||
.state-btn {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
background: #111827;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.state-card {
|
||||
padding: 56rpx 40rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.state-title {
|
||||
display: block;
|
||||
font-size: 32rpx;
|
||||
color: #232734;
|
||||
font-weight: 800;
|
||||
margin-bottom: 14rpx;
|
||||
}
|
||||
|
||||
.state-desc {
|
||||
display: block;
|
||||
font-size: 25rpx;
|
||||
color: #8a90a0;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -5,7 +5,7 @@
|
||||
<view class="nav-content">
|
||||
<view class="nav-left" @tap="goBack">
|
||||
<text class="back-icon"></text>
|
||||
<text class="nav-title">全民评分</text>
|
||||
<text class="nav-title">夯拉评分</text>
|
||||
</view>
|
||||
<view class="nav-right">
|
||||
<text class="search-icon"></text>
|
||||
@@ -27,7 +27,10 @@
|
||||
</view>
|
||||
<view class="hero-meta">
|
||||
<text class="meta-rank">🥇 No.1</text>
|
||||
<text class="meta-heat">🔥 {{ detailData.hotScore }}w 热度</text>
|
||||
<view class="meta-heat">
|
||||
<image class="meta-icon" src="/static/images/icon/fire.png" mode="aspectFit"></image>
|
||||
<text>{{ detailData.hotScore }}w 热度</text>
|
||||
</view>
|
||||
<text class="meta-count" style="margin-left: 20rpx; color: #999;">{{ detailData.ratingCount }} 人评分</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -36,7 +39,7 @@
|
||||
<!-- AI 观察员 -->
|
||||
<view class="ai-card">
|
||||
<view class="ai-header">
|
||||
<text class="ai-icon"></text>
|
||||
<image class="ai-icon ai-icon-img" src="/static/images/icon/robot.png" mode="aspectFit"></image>
|
||||
<text class="ai-title">AI观察员</text>
|
||||
</view>
|
||||
<text class="ai-content">{{ detailData.aiSummary }}</text>
|
||||
@@ -44,94 +47,119 @@
|
||||
|
||||
<!-- 你的态度 -->
|
||||
<AttitudePanel
|
||||
ref="attitudePanelRef"
|
||||
:initial-action="currentAction"
|
||||
:sending="isSendingComment"
|
||||
@action-change="handleActionChange"
|
||||
@send-comment="handleSendComment"
|
||||
/>
|
||||
|
||||
<!-- 大家都在 PK -->
|
||||
<view class="pk-panel card">
|
||||
<!-- <view v-if="pkPreview" class="pk-panel card" @tap="goToPk()">
|
||||
<view class="pk-header">
|
||||
<text class="section-title">大家都在 PK</text>
|
||||
<view class="go-compare">去对比<text class="arrow-right"></text></view>
|
||||
</view>
|
||||
<view class="pk-content">
|
||||
<view class="pk-person">
|
||||
<image class="pk-avatar blue-border" :src="detailData.avatar" mode="aspectFill" />
|
||||
<text class="pk-name">{{ detailData.name }}</text>
|
||||
<image class="pk-avatar blue-border" :src="pkPreview.left.avatar" mode="aspectFill" />
|
||||
<text class="pk-name">{{ pkPreview.left.name }}</text>
|
||||
<text class="pk-score blue-text">{{ pkPreview.left.scoreText }}</text>
|
||||
</view>
|
||||
<text class="vs-text">VS</text>
|
||||
<view class="pk-person">
|
||||
<image class="pk-avatar yellow-border" src="https://api.dicebear.com/7.x/avataaars/svg?seed=13" mode="aspectFill" />
|
||||
<text class="pk-name">康熙</text>
|
||||
<image class="pk-avatar yellow-border" :src="pkPreview.right.avatar" mode="aspectFill" />
|
||||
<text class="pk-name">{{ pkPreview.right.name }}</text>
|
||||
<text class="pk-score yellow-text">{{ pkPreview.right.scoreText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="pk-bar-wrap">
|
||||
<view class="pk-bar blue-bar" style="width: 63%;"></view>
|
||||
<view class="pk-bar yellow-bar" style="width: 37%;"></view>
|
||||
<view class="pk-bar blue-bar" :style="{ width: `${pkPreview.leftPercent}%` }"></view>
|
||||
<view class="pk-bar yellow-bar" :style="{ width: `${pkPreview.rightPercent}%` }"></view>
|
||||
</view>
|
||||
<view class="pk-data-text">
|
||||
<text class="blue-text">63% 支持</text>
|
||||
<text class="yellow-text">37% 支持</text>
|
||||
<text class="blue-text">{{ pkPreview.leftPercent }}% 看好</text>
|
||||
<text class="yellow-text">{{ pkPreview.rightPercent }}% 看好</text>
|
||||
</view>
|
||||
</view>
|
||||
</view> -->
|
||||
|
||||
<!-- 评论区 -->
|
||||
<view class="comments-section card">
|
||||
<view class="tabs-header">
|
||||
<view class="tabs">
|
||||
<text class="tab" :class="{ active: currentTab === 'hot' }" @tap="switchTab('hot')">最热</text>
|
||||
<text class="tab" :class="{ active: currentTab === 'new' }" @tap="switchTab('new')">最新</text>
|
||||
</view>
|
||||
<!-- <text class="filter-icon"></text> -->
|
||||
</view>
|
||||
|
||||
<view class="comment-list" style="min-height: 400rpx;">
|
||||
<CommentItem
|
||||
v-for="(comment, index) in comments"
|
||||
:key="comment.id"
|
||||
:comment="comment"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<view class="load-more">
|
||||
<text>{{ loading ? '加载中...' : (hasMore ? '上拉加载更多' : '没有更多了') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<CommentSection
|
||||
ref="commentSectionRef"
|
||||
variant="topic"
|
||||
section-title="评论区"
|
||||
:request-key="commentRequestKey"
|
||||
:visible="!!commentRequestKey"
|
||||
:allow-compose="false"
|
||||
page-name="rating_detail"
|
||||
loading-desc="正在拉取大家的锐评"
|
||||
empty-title="还没有评论"
|
||||
empty-desc="来发第一条锐评,带动一下讨论气氛"
|
||||
empty-tip="你的观点,可能就是这条榜单最有意思的开始"
|
||||
:show-empty-visual="true"
|
||||
load-more-mode="scroll"
|
||||
footer-hint-text="上拉加载更多"
|
||||
:show-success-toast="false"
|
||||
:list-handler="fetchTopicComments"
|
||||
:replies-handler="fetchTopicCommentReplies"
|
||||
:publish-handler="publishTopicComment"
|
||||
:normalize-comment="normalizeCommentItem"
|
||||
@need-login="openLoginPopup"
|
||||
@published="handleCommentPublished"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 评分成功特效弹窗 -->
|
||||
<SuccessPopup ref="successPopupRef" />
|
||||
<LoginPopup ref="loginPopupRef" @logind="handleLoginSuccess" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { getStatusBarHeight } from "@/utils/system";
|
||||
import { onLoad, onPullDownRefresh, onReachBottom } from "@dcloudio/uni-app";
|
||||
import { onLoad, onPullDownRefresh, onReachBottom, onShareAppMessage, onShareTimeline } from "@dcloudio/uni-app";
|
||||
import AttitudePanel from "@/components/AttitudePanel/AttitudePanel.vue";
|
||||
import SuccessPopup from "@/components/SuccessPopup/SuccessPopup.vue";
|
||||
import ActionBadge from "@/components/ActionBadge/ActionBadge.vue";
|
||||
import CommentItem from "@/components/CommentItem/CommentItem.vue";
|
||||
import CommentSection from "@/components/CommentSection/CommentSection.vue";
|
||||
import LoginPopup from "@/components/LoginPopup/LoginPopup.vue";
|
||||
import { FILE_BASE_URL } from "@/utils/constants";
|
||||
import { fetchTopicRatingItems, topicItemScore, topicItemComment, topicItemCommentList } from "@/api/topicItem";
|
||||
import { fetchTopicRatingItems as fetchTopicItemList } from "@/api/topic";
|
||||
import { fetchTopicRatingItems, topicItemScore, topicItemComment, topicItemCommentList, topicItemCommentReplyList } from "@/api/topicItem";
|
||||
import { formatDate } from "@/utils/date";
|
||||
|
||||
|
||||
|
||||
import { getShareToken } from "@/utils/common";
|
||||
import { getShareReward } from "@/api/system";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
const userStore = useUserStore();
|
||||
const statusBarHeight = ref(getStatusBarHeight() || 44);
|
||||
const loading = ref(false);
|
||||
const hasMore = ref(true);
|
||||
const currentPage = ref(1);
|
||||
const currentAction = ref('');
|
||||
const successPopupRef = ref(null);
|
||||
const currentTab = ref('hot'); // 'hot' 或 'new'
|
||||
const loginPopupRef = ref(null);
|
||||
const attitudePanelRef = ref(null);
|
||||
const commentSectionRef = ref(null);
|
||||
const isLoggedIn = computed(() => !!userStore.token || !!userStore.userInfo?.nickName);
|
||||
const isSendingComment = ref(false);
|
||||
const pkPreview = ref(null);
|
||||
const pendingAnchorCommentId = ref('');
|
||||
const commentRequestKey = computed(() => {
|
||||
if (!detailData.value.topicId || !itemId.value) return '';
|
||||
return `${detailData.value.topicId}_${itemId.value}`;
|
||||
});
|
||||
|
||||
const goBack = () => {
|
||||
uni.navigateBack();
|
||||
};
|
||||
|
||||
const openLoginPopup = (message = '请先登录后再操作') => {
|
||||
loginPopupRef.value?.open();
|
||||
if (message) {
|
||||
uni.showToast({
|
||||
title: message,
|
||||
icon: 'none'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const detailData = ref({
|
||||
id: '',
|
||||
name: '',
|
||||
@@ -141,11 +169,116 @@ const detailData = ref({
|
||||
userAction: "",
|
||||
topicId: ''
|
||||
});
|
||||
|
||||
const comments = ref([]);
|
||||
const itemId = ref('');
|
||||
|
||||
const buildAvatarUrl = (avatar) => {
|
||||
if (!avatar) return 'https://api.dicebear.com/7.x/avataaars/svg?seed=fallback';
|
||||
const normalizedAvatar = String(avatar).trim();
|
||||
return normalizedAvatar.startsWith('http') ? normalizedAvatar : `${FILE_BASE_URL}${normalizedAvatar}`;
|
||||
};
|
||||
|
||||
const normalizeCommentItem = (item = {}, rootComment = null) => {
|
||||
const user = item.user || {};
|
||||
const replyToUser = item.replyToUser || {};
|
||||
return {
|
||||
id: item.id,
|
||||
rootCommentId: item.rootCommentId || rootComment?.id || null,
|
||||
parentCommentId: item.parentCommentId || rootComment?.id || null,
|
||||
replyToCommentId: item.replyToCommentId || null,
|
||||
name: user.nickname || '匿名用户',
|
||||
avatar: buildAvatarUrl(user.avatar),
|
||||
time: formatDate(item.createdAt) || '刚刚',
|
||||
content: item.content || '',
|
||||
likes: Number(item.likeCount || 0),
|
||||
isLiked: !!item.isLiked,
|
||||
badge: '',
|
||||
badgeIcon: '',
|
||||
userAction: item.userAction || '',
|
||||
replyCount: Number(item.replyCount || 0),
|
||||
replyToName: replyToUser.nickname || '',
|
||||
repliesLoading: false,
|
||||
repliesLoaded: !!rootComment,
|
||||
repliesHasMore: false,
|
||||
repliesPage: 1,
|
||||
replies: rootComment ? [] : []
|
||||
};
|
||||
};
|
||||
|
||||
const calcPkPercent = (leftScore, rightScore) => {
|
||||
const left = Number(leftScore) || 0;
|
||||
const right = Number(rightScore) || 0;
|
||||
if (left <= 0 && right <= 0) {
|
||||
return { leftPercent: 50, rightPercent: 50 };
|
||||
}
|
||||
const total = left + right || 1;
|
||||
let leftPercent = Math.round((left / total) * 100);
|
||||
leftPercent = Math.min(82, Math.max(18, leftPercent));
|
||||
return {
|
||||
leftPercent,
|
||||
rightPercent: 100 - leftPercent
|
||||
};
|
||||
};
|
||||
|
||||
const buildPkPreview = (items) => {
|
||||
const sortedItems = [...items].sort((a, b) => (Number(b.scoreAvg) || 0) - (Number(a.scoreAvg) || 0));
|
||||
const currentIndex = sortedItems.findIndex((item) => String(item.id) === String(itemId.value));
|
||||
if (currentIndex === -1) return null;
|
||||
|
||||
const currentItem = sortedItems[currentIndex];
|
||||
const rival =
|
||||
sortedItems[currentIndex === 0 ? 1 : currentIndex - 1] ||
|
||||
sortedItems.find((item) => String(item.id) !== String(itemId.value));
|
||||
|
||||
if (!currentItem || !rival) return null;
|
||||
|
||||
const { leftPercent, rightPercent } = calcPkPercent(currentItem.scoreAvg, rival.scoreAvg);
|
||||
return {
|
||||
left: {
|
||||
id: currentItem.id,
|
||||
name: currentItem.name || '当前对象',
|
||||
avatar: buildAvatarUrl(currentItem.avatarUrl),
|
||||
scoreText: `${Number(currentItem.scoreAvg || 0).toFixed(1)} 分`
|
||||
},
|
||||
right: {
|
||||
id: rival.id,
|
||||
name: rival.name || '对手',
|
||||
avatar: buildAvatarUrl(rival.avatarUrl),
|
||||
scoreText: `${Number(rival.scoreAvg || 0).toFixed(1)} 分`
|
||||
},
|
||||
leftPercent,
|
||||
rightPercent
|
||||
};
|
||||
};
|
||||
|
||||
const getPkPreviewData = async () => {
|
||||
if (!detailData.value.topicId || !itemId.value) {
|
||||
pkPreview.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetchTopicItemList(detailData.value.topicId, 1);
|
||||
const rawList = res?.data?.records || res?.records || res?.data?.list || res?.list || [];
|
||||
const list = Array.isArray(rawList) ? rawList : [];
|
||||
pkPreview.value = list.length >= 2 ? buildPkPreview(list) : null;
|
||||
} catch (error) {
|
||||
pkPreview.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const goToPk = () => {
|
||||
if (!detailData.value.topicId || !itemId.value) return;
|
||||
uni.navigateTo({
|
||||
url: `/pages/rating/pk?topicId=${detailData.value.topicId}&itemId=${itemId.value}`
|
||||
});
|
||||
};
|
||||
|
||||
const handleActionChange = async (action) => {
|
||||
if (!isLoggedIn.value) {
|
||||
openLoginPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
const originAction = detailData.value.userAction;
|
||||
const originScore = detailData.value.score;
|
||||
const isCancel = detailData.value.userAction === action.label;
|
||||
@@ -165,6 +298,20 @@ const handleActionChange = async (action) => {
|
||||
if (!isCancel && successPopupRef.value) {
|
||||
successPopupRef.value.show(action);
|
||||
}
|
||||
uni.$trackRecord({
|
||||
eventName: 'rating',
|
||||
eventType: 'rating',
|
||||
elementId: `item_${itemId.value}`,
|
||||
elementContent: `评分项_${detailData.value.name}`,
|
||||
customParams: {
|
||||
scoreType: action.scoreType,
|
||||
action: action.label,
|
||||
topicId: detailData.value.topicId,
|
||||
topicTitle: detailData.value.name,
|
||||
score: detailData.value.score,
|
||||
page: 'rating_detail'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
detailData.value.userAction = originAction;
|
||||
detailData.value.score = originScore;
|
||||
@@ -172,83 +319,95 @@ const handleActionChange = async (action) => {
|
||||
}
|
||||
};
|
||||
|
||||
const publishTopicComment = ({ content, replyTarget, mode }) => {
|
||||
return topicItemComment({
|
||||
topicId: detailData.value.topicId,
|
||||
itemId: itemId.value,
|
||||
content,
|
||||
parentCommentId: mode === 'reply' ? replyTarget?.parentCommentId : undefined,
|
||||
replyToCommentId: mode === 'reply' ? replyTarget?.replyToCommentId : undefined
|
||||
});
|
||||
};
|
||||
|
||||
const fetchTopicComments = ({ page, orderBy }) => {
|
||||
return topicItemCommentList(detailData.value.topicId, itemId.value, page, orderBy);
|
||||
};
|
||||
|
||||
const fetchTopicCommentReplies = ({ rootCommentId, page }) => {
|
||||
return topicItemCommentReplyList({
|
||||
rootCommentId,
|
||||
page
|
||||
});
|
||||
};
|
||||
|
||||
const handleCommentPublished = ({ mode, content, replyTarget }) => {
|
||||
if (successPopupRef.value) {
|
||||
successPopupRef.value.show({
|
||||
emoji: '💬',
|
||||
label: mode === 'reply' ? '回复已发送' : '你的锐评已发布'
|
||||
}, 'comment');
|
||||
}
|
||||
|
||||
uni.$trackRecord({
|
||||
eventName: 'comment',
|
||||
eventType: 'comment',
|
||||
elementId: `item_${itemId.value}`,
|
||||
elementContent: `评论项_${detailData.value.name}`,
|
||||
customParams: {
|
||||
comment: content,
|
||||
replyToCommentId: replyTarget?.replyToCommentId || '',
|
||||
page: 'rating_detail'
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const scrollToPendingComment = async () => {
|
||||
if (!pendingAnchorCommentId.value) return;
|
||||
const found = await commentSectionRef.value?.scrollToComment?.(pendingAnchorCommentId.value);
|
||||
if (found) {
|
||||
pendingAnchorCommentId.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendComment = async (payload) => {
|
||||
if (!detailData.value.topicId || !itemId.value) return;
|
||||
if (!isLoggedIn.value) {
|
||||
openLoginPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!detailData.value.topicId || !itemId.value || isSendingComment.value) return;
|
||||
|
||||
try {
|
||||
isSendingComment.value = true;
|
||||
uni.showLoading({ title: '发送中...' });
|
||||
await topicItemComment({
|
||||
topicId: detailData.value.topicId,
|
||||
itemId: itemId.value,
|
||||
content: payload.content
|
||||
const res = await publishTopicComment({
|
||||
content: payload.content,
|
||||
mode: 'comment',
|
||||
replyTarget: null
|
||||
});
|
||||
uni.hideLoading();
|
||||
|
||||
// 弹出成功弹窗
|
||||
if (successPopupRef.value) {
|
||||
successPopupRef.value.show({
|
||||
emoji: '💬',
|
||||
label: '你的锐评已发布'
|
||||
}, 'comment');
|
||||
if(res?.success === false) {
|
||||
uni.showToast({
|
||||
title: res?.message || '发送失败',
|
||||
icon: 'none'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 刷新页面数据以获取最新评论
|
||||
currentPage.value = 1;
|
||||
hasMore.value = true;
|
||||
comments.value = [];
|
||||
getCommentsData();
|
||||
handleCommentPublished({
|
||||
mode: 'comment',
|
||||
content: payload.content,
|
||||
replyTarget: null
|
||||
});
|
||||
attitudePanelRef.value?.resetComment?.();
|
||||
await commentSectionRef.value?.refreshComments?.();
|
||||
} catch (error) {
|
||||
uni.hideLoading();
|
||||
uni.showToast({
|
||||
title: '发送失败',
|
||||
icon: 'none'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const switchTab = (tab) => {
|
||||
if (currentTab.value === tab) return;
|
||||
currentTab.value = tab;
|
||||
currentPage.value = 1;
|
||||
hasMore.value = true;
|
||||
// comments.value = []; // 移除这行,避免数据清空导致高度塌陷
|
||||
getCommentsData(true); // 传入标识表示是切换榜单
|
||||
};
|
||||
|
||||
const getCommentsData = async (isSwitchTab = false) => {
|
||||
if (!detailData.value.topicId || !itemId.value || loading.value) return;
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
const orderBy = currentTab.value;
|
||||
const res = await topicItemCommentList(detailData.value.topicId, itemId.value, currentPage.value, orderBy);
|
||||
const list = res?.list || [];
|
||||
|
||||
const formattedList = list.map(item => ({
|
||||
id: item.id,
|
||||
name: item.user?.nickname || '匿名用户',
|
||||
avatar: item.user?.avatar ? item.user.avatar : 'https://api.dicebear.com/7.x/avataaars/svg?seed=fallback',
|
||||
time: formatDate(item.createdAt) || '刚刚', // 如果后端返回了时间字段可以替换
|
||||
content: item.content,
|
||||
likes: item.likeCount || 0,
|
||||
isLiked: item.isLiked || false,
|
||||
badge: '', // 预留徽章字段
|
||||
badgeIcon: '',
|
||||
userAction: item.userAction || "" // 夯/顶级/人上人/NPC/拉
|
||||
}));
|
||||
|
||||
if (currentPage.value === 1 || isSwitchTab) {
|
||||
comments.value = formattedList;
|
||||
} else {
|
||||
comments.value = [...comments.value, ...formattedList];
|
||||
}
|
||||
|
||||
// 假设每页10条,如果返回的少于10条则没有更多了
|
||||
hasMore.value = list.length >= 6;
|
||||
} catch (error) {
|
||||
console.error('获取评论列表失败:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
isSendingComment.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -271,21 +430,11 @@ const getDetailData = async () => {
|
||||
userAction: data.userAction || ""
|
||||
};
|
||||
|
||||
// 如果接口返回了当前用户的态度,初始化它
|
||||
if (data.userAction) {
|
||||
currentAction.value = data.userAction;
|
||||
}
|
||||
currentAction.value = data.userAction || '';
|
||||
|
||||
// 如果有评论数据,更新评论 (兼容老接口一并返回的情况,但推荐通过 getCommentsData 单独获取)
|
||||
if (data.comments && Array.isArray(data.comments) && currentPage.value === 1) {
|
||||
// 如果后端在详情里依然返回了一部分评论
|
||||
// comments.value = data.comments;
|
||||
}
|
||||
|
||||
// 拉取真实的评论列表
|
||||
if (currentPage.value === 1) {
|
||||
getCommentsData();
|
||||
}
|
||||
await commentSectionRef.value?.refreshComments?.();
|
||||
await scrollToPendingComment();
|
||||
getPkPreviewData();
|
||||
} catch (error) {
|
||||
console.error('获取详情失败:', error);
|
||||
uni.showToast({
|
||||
@@ -307,23 +456,46 @@ onLoad((options) => {
|
||||
if (options.topicId) {
|
||||
detailData.value.topicId = options.topicId;
|
||||
}
|
||||
if (options.commentId) {
|
||||
pendingAnchorCommentId.value = options.commentId;
|
||||
}
|
||||
if (options.itemId) {
|
||||
itemId.value = options.itemId;
|
||||
getDetailData();
|
||||
}
|
||||
});
|
||||
|
||||
const handleLoginSuccess = () => {
|
||||
getDetailData();
|
||||
};
|
||||
|
||||
onShareAppMessage(async () => {
|
||||
const shareToken = await getShareToken("rating_item");
|
||||
getShareReward({ scene: "rating_item" });
|
||||
const itemName = detailData.value.name || "这个评分对象";
|
||||
return {
|
||||
title: `「${itemName}」在夯拉评分里是什么段位?`,
|
||||
path: `/pages/rating/detail?itemId=${itemId.value}&topicId=${detailData.value.topicId}&shareToken=${shareToken}`,
|
||||
};
|
||||
});
|
||||
|
||||
onShareTimeline(async () => {
|
||||
const shareToken = await getShareToken("rating_item");
|
||||
getShareReward({ scene: "rating_item_timeline" });
|
||||
const itemName = detailData.value.name || "这个评分对象";
|
||||
const score = detailData.value.score || "0.0";
|
||||
return {
|
||||
title: `${itemName}当前评分 ${score},来看看大家怎么评`,
|
||||
query: `itemId=${itemId.value}&topicId=${detailData.value.topicId}&shareToken=${shareToken}`,
|
||||
};
|
||||
});
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
currentPage.value = 1;
|
||||
hasMore.value = true;
|
||||
comments.value = [];
|
||||
getDetailData();
|
||||
});
|
||||
|
||||
onReachBottom(() => {
|
||||
if (!hasMore.value || loading.value) return;
|
||||
currentPage.value += 1;
|
||||
getCommentsData();
|
||||
commentSectionRef.value?.loadMoreComments?.();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -456,9 +628,18 @@ onReachBottom(() => {
|
||||
}
|
||||
|
||||
.meta-heat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.meta-icon {
|
||||
width: 24rpx;
|
||||
height: 24rpx;
|
||||
margin-right: 8rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* AI 观察员 */
|
||||
.ai-card {
|
||||
background: #ffffff;
|
||||
@@ -477,11 +658,13 @@ onReachBottom(() => {
|
||||
.ai-icon {
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%237B46F1'%3E%3Cpath d='M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h5a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h5V5.73A2 2 0 0 1 10 4a2 2 0 0 1 2-2zm-3 8a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zm6 0a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3z'/%3E%3C/svg%3E");
|
||||
background-size: cover;
|
||||
margin-right: 12rpx;
|
||||
}
|
||||
|
||||
.ai-icon-img {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ai-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 800;
|
||||
@@ -562,6 +745,12 @@ onReachBottom(() => {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.pk-score {
|
||||
margin-top: 8rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.vs-text {
|
||||
font-size: 40rpx;
|
||||
font-weight: 900;
|
||||
@@ -590,72 +779,4 @@ onReachBottom(() => {
|
||||
.blue-text { color: #4d44f1; }
|
||||
.yellow-text { color: #333; }
|
||||
|
||||
/* 评论区 */
|
||||
.comments-section {
|
||||
padding: 32rpx;
|
||||
}
|
||||
|
||||
.tabs-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 40rpx;
|
||||
}
|
||||
|
||||
.tab {
|
||||
font-size: 32rpx;
|
||||
color: #999;
|
||||
font-weight: 500;
|
||||
position: relative;
|
||||
padding-bottom: 12rpx;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Helvetica, Segoe UI, Arial, Roboto, 'PingFang SC', 'miui', 'Hiragino Sans GB', 'Microsoft Yahei', sans-serif;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: #111;
|
||||
font-size: 36rpx;
|
||||
font-weight: 700; /* 稍微减轻字重,从800改为700,显得不那么生硬 */
|
||||
}
|
||||
|
||||
.tab.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 32rpx;
|
||||
height: 8rpx;
|
||||
background: linear-gradient(90deg, #4d44f1, #8a78ff);
|
||||
border-radius: 4rpx;
|
||||
}
|
||||
|
||||
.filter-icon {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='4' y1='21' x2='4' y2='14'%3E%3C/line%3E%3Cline x1='4' y1='10' x2='4' y2='3'%3E%3C/line%3E%3Cline x1='12' y1='21' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='8' x2='12' y2='3'%3E%3C/line%3E%3Cline x1='20' y1='21' x2='20' y2='16'%3E%3C/line%3E%3Cline x1='20' y1='12' x2='20' y2='3'%3E%3C/line%3E%3Cline x1='1' y1='14' x2='7' y2='14'%3E%3C/line%3E%3Cline x1='9' y1='8' x2='15' y2='8'%3E%3C/line%3E%3Cline x1='17' y1='16' x2='23' y2='16'%3E%3C/line%3E%3C/svg%3E");
|
||||
background-size: cover;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.filter-icon:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.comment-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.load-more {
|
||||
text-align: center;
|
||||
padding: 40rpx 0 0;
|
||||
color: #999;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
<text class="back-icon"></text>
|
||||
</view>
|
||||
<view class="nav-logo-wrap">
|
||||
<image class="nav-logo" src="/static/images/default-avatar.png" mode="aspectFill" />
|
||||
<image class="nav-logo" src="/static/images/logo.jpg" mode="aspectFill" />
|
||||
</view>
|
||||
<text class="nav-title">全民评分</text>
|
||||
<text class="nav-title">夯拉评分</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -21,21 +21,46 @@
|
||||
<view class="topic-header">
|
||||
<text class="topic-title">{{ topicData.title }}</text>
|
||||
<view class="topic-stats">
|
||||
<text class="stat-icon fire-icon"></text>
|
||||
<image class="stat-icon stat-icon-img" src="/static/images/icon/fire.png" mode="aspectFit"></image>
|
||||
<text class="stat-text">热度 {{ topicData.hotScore }} w</text>
|
||||
<text class="stat-text margin-left">{{ topicData.participantCount }}人参与</text>
|
||||
<image class="stat-icon stat-icon-img stat-icon-gap" src="/static/images/icon/group.png" mode="aspectFit"></image>
|
||||
<text class="stat-text stat-text-participant">{{ topicData.participantCount }}人参与</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- AI 实时观察 -->
|
||||
<view class="ai-observe">
|
||||
<view class="ai-header">
|
||||
<text class="ai-icon"></text>
|
||||
<image class="ai-icon ai-icon-img" src="/static/images/icon/robot.png" mode="aspectFit"></image>
|
||||
<text class="ai-title">AI 实时观察</text>
|
||||
</view>
|
||||
<text class="ai-content">{{ topicData.aiSummary }}</text>
|
||||
</view>
|
||||
|
||||
<!-- <view v-if="pkPair.left && pkPair.right" class="pk-entry-card" @tap="goToPk()">
|
||||
<view class="pk-entry-header">
|
||||
<view class="pk-entry-title-wrap">
|
||||
<text class="pk-entry-title">PK 对决模式</text>
|
||||
<text class="pk-entry-subtitle">快速站队,看谁更能打</text>
|
||||
</view>
|
||||
<view class="pk-entry-action">
|
||||
<text>进入 PK</text>
|
||||
<text class="pk-entry-arrow"></text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="pk-entry-battle">
|
||||
<view class="pk-entry-user left">
|
||||
<image class="pk-entry-avatar" :src="FILE_BASE_URL + pkPair.left.avatarUrl" mode="aspectFill" />
|
||||
<text class="pk-entry-name">{{ pkPair.left.name }}</text>
|
||||
</view>
|
||||
<view class="pk-entry-vs">VS</view>
|
||||
<view class="pk-entry-user right">
|
||||
<image class="pk-entry-avatar" :src="FILE_BASE_URL + pkPair.right.avatarUrl" mode="aspectFill" />
|
||||
<text class="pk-entry-name">{{ pkPair.right.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view> -->
|
||||
|
||||
<!-- 领奖台 (Top 3) -->
|
||||
<view class="podium-section" v-if="ratingItems && ratingItems.length >= 3">
|
||||
<!-- 第二名 -->
|
||||
@@ -118,7 +143,7 @@
|
||||
</view>
|
||||
|
||||
<!-- 右下角悬浮添加按钮 -->
|
||||
<view class="fab-btn" @tap="showAddPopup = true">
|
||||
<view class="fab-btn" @tap="handleAddClick">
|
||||
<text class="plus-icon">+</text>
|
||||
</view>
|
||||
|
||||
@@ -150,19 +175,26 @@
|
||||
|
||||
<!-- 评分成功特效弹窗 -->
|
||||
<SuccessPopup ref="successPopupRef" />
|
||||
<LoginPopup ref="loginPopupRef" @logind="handleLoginSuccess" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { getStatusBarHeight } from "@/utils/system";
|
||||
import { onPullDownRefresh, onReachBottom, onLoad } from "@dcloudio/uni-app";
|
||||
import { onPullDownRefresh, onReachBottom, onLoad, onShareAppMessage, onShareTimeline } from "@dcloudio/uni-app";
|
||||
import { fetchTopicDetail, fetchTopicRatingItems } from "@/api/topic";
|
||||
import { FILE_BASE_URL } from "@/utils/constants";
|
||||
import RatingCard from "@/components/RatingCard/RatingCard.vue";
|
||||
import SuccessPopup from "@/components/SuccessPopup/SuccessPopup.vue";
|
||||
import { topicItemScore } from "@/api/topicItem";
|
||||
import LoginPopup from "@/components/LoginPopup/LoginPopup.vue";
|
||||
import { topicItemScore, topicItemAdd } from "@/api/topicItem";
|
||||
import { uploadImage } from "@/utils/common";
|
||||
import { getShareToken } from "@/utils/common";
|
||||
import { getShareReward } from "@/api/system";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
// 状态栏高度处理
|
||||
const userStore = useUserStore();
|
||||
const statusBarHeight = ref(getStatusBarHeight() || 44);
|
||||
|
||||
const loading = ref(false);
|
||||
@@ -170,6 +202,12 @@ const hasMore = ref(true);
|
||||
const currentPage = ref(1);
|
||||
const currentTopicId = ref(null);
|
||||
const successPopupRef = ref(null);
|
||||
const loginPopupRef = ref(null);
|
||||
const isLoggedIn = computed(() => !!userStore.token || !!userStore.userInfo?.nickName);
|
||||
const pkPair = computed(() => ({
|
||||
left: ratingItems.value?.[0] || null,
|
||||
right: ratingItems.value?.[1] || null
|
||||
}));
|
||||
|
||||
const goBack = () => {
|
||||
uni.navigateBack({
|
||||
@@ -177,6 +215,13 @@ const goBack = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const openLoginPopup = (message = '请先登录后再操作') => {
|
||||
loginPopupRef.value?.open();
|
||||
if (message) {
|
||||
uni.showToast({ title: message, icon: 'none' });
|
||||
}
|
||||
};
|
||||
|
||||
const topicData = ref({
|
||||
title: '',
|
||||
heat: '0',
|
||||
@@ -208,6 +253,11 @@ const newItem = ref({
|
||||
});
|
||||
|
||||
const chooseAvatar = () => {
|
||||
if (!isLoggedIn.value) {
|
||||
openLoginPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
success: (res) => {
|
||||
@@ -216,30 +266,57 @@ const chooseAvatar = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const confirmAddItem = () => {
|
||||
const confirmAddItem = async () => {
|
||||
if (!isLoggedIn.value) {
|
||||
openLoginPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newItem.value.name.trim()) {
|
||||
uni.showToast({ title: '请输入名称', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (!newItem.value.avatar) {
|
||||
uni.showToast({ title: '请上传头像', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 模拟添加
|
||||
const newRatingItem = {
|
||||
id: Date.now(),
|
||||
rank: ratingItems.value.length + 1,
|
||||
name: newItem.value.name,
|
||||
avatar: newItem.value.avatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${Date.now()}`,
|
||||
score: '0.0',
|
||||
quote: '刚刚加入讨论,快来发表你的看法吧。',
|
||||
userAction: ''
|
||||
};
|
||||
|
||||
ratingItems.value.push(newRatingItem);
|
||||
|
||||
// 重置并关闭
|
||||
newItem.value = { name: '', avatar: '' };
|
||||
showAddPopup.value = false;
|
||||
|
||||
uni.showToast({ title: '添加成功', icon: 'success' });
|
||||
try {
|
||||
uni.showLoading({ title: '添加中...' });
|
||||
|
||||
// 1. 先通过 uni.uploadFile 上传头像图片
|
||||
const uploadRes = await uploadImage(newItem.value.avatar);
|
||||
|
||||
// 2. 将上传成功后的图片 URL 和其他数据一起提交给添加接口
|
||||
await topicItemAdd({
|
||||
topicId: currentTopicId.value,
|
||||
name: newItem.value.name,
|
||||
avatarUrl: uploadRes
|
||||
});
|
||||
|
||||
uni.hideLoading();
|
||||
// uni.showToast({ title: '添加成功', icon: 'success' });
|
||||
if (successPopupRef.value) {
|
||||
successPopupRef.value.show({
|
||||
emoji: '🎉',
|
||||
label: '请耐心等待管理员审核'
|
||||
}, 'release');
|
||||
}
|
||||
|
||||
// 重置表单并关闭弹窗
|
||||
newItem.value = { name: '', avatar: '' };
|
||||
showAddPopup.value = false;
|
||||
|
||||
// 重新加载列表数据
|
||||
currentPage.value = 1;
|
||||
hasMore.value = true;
|
||||
loadItems(currentTopicId.value, 1);
|
||||
|
||||
} catch (error) {
|
||||
uni.hideLoading();
|
||||
uni.showToast({ title: '添加失败,请重试', icon: 'none' });
|
||||
console.error('添加评分项失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const ratingItems = ref([]);
|
||||
@@ -256,7 +333,6 @@ const loadItems = async (topicId, page = 1) => {
|
||||
ratingItems.value = [...ratingItems.value, ...list];
|
||||
hasMore.value = list.length >= 10; // 假设 pageSize 是 10
|
||||
} catch (error) {
|
||||
console.error("获取评分项列表失败:", error);
|
||||
hasMore.value = false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
@@ -276,26 +352,12 @@ onLoad((options) => {
|
||||
}
|
||||
});
|
||||
|
||||
const mockComments = [
|
||||
{
|
||||
id: 1,
|
||||
name: '史学家阿强',
|
||||
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=c1',
|
||||
time: '刚刚',
|
||||
content: '李世民唯一缺点是儿子太抽象。他在位时那种包容力和大局观,放眼整个历史都是炸裂的存在。'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '逻辑帝',
|
||||
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=c2',
|
||||
time: '2小时前',
|
||||
content: '秦始皇是开辟者,李世民是完善者。一个是0到1的惊天动地,一个是1到100的极致巅峰。'
|
||||
}
|
||||
];
|
||||
|
||||
const comments = ref([...mockComments]);
|
||||
|
||||
const handleAction = async (item, action) => {
|
||||
if (!isLoggedIn.value) {
|
||||
openLoginPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
const originAction = item.userAction;
|
||||
const originScore = item.scoreAvg;
|
||||
const isCancel = item.userAction === action.label;
|
||||
@@ -313,6 +375,20 @@ const handleAction = async (item, action) => {
|
||||
if (!isCancel && successPopupRef.value) {
|
||||
successPopupRef.value.show(action);
|
||||
}
|
||||
uni.$trackRecord({
|
||||
eventName: 'rating',
|
||||
eventType: 'rating',
|
||||
elementId: `item_${item.id}`,
|
||||
elementContent: `评分项_${item.name}`,
|
||||
customParams: {
|
||||
scoreType: action.scoreType,
|
||||
action: action.label,
|
||||
topicId: currentTopicId.value,
|
||||
topicTitle: topicData.value.title,
|
||||
score: item.scoreAvg,
|
||||
page: 'rating'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
item.userAction = originAction;
|
||||
item.scoreAvg = originScore;
|
||||
@@ -320,11 +396,66 @@ const handleAction = async (item, action) => {
|
||||
};
|
||||
|
||||
const goToDetail = (itemId) => {
|
||||
uni.$trackRecord({
|
||||
eventName: 'jumpto_detail',
|
||||
eventType: 'jump',
|
||||
elementId: `item_${itemId}`,
|
||||
elementContent: `评分项${itemId}`,
|
||||
});
|
||||
uni.navigateTo({
|
||||
url: `/pages/rating/detail?itemId=${itemId}&topicId=${currentTopicId.value}`
|
||||
});
|
||||
};
|
||||
|
||||
const goToPk = (itemId = '') => {
|
||||
if (!currentTopicId.value) return;
|
||||
uni.navigateTo({
|
||||
url: `/pages/rating/pk?topicId=${currentTopicId.value}${itemId ? `&itemId=${itemId}` : ''}`
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddClick = () => {
|
||||
if (!isLoggedIn.value) {
|
||||
openLoginPopup();
|
||||
return;
|
||||
}
|
||||
showAddPopup.value = true;
|
||||
};
|
||||
|
||||
const handleLoginSuccess = async () => {
|
||||
if (!currentTopicId.value) return;
|
||||
currentPage.value = 1;
|
||||
hasMore.value = true;
|
||||
await Promise.all([
|
||||
loadDetail(currentTopicId.value),
|
||||
loadItems(currentTopicId.value, 1)
|
||||
]);
|
||||
};
|
||||
|
||||
onShareAppMessage(async () => {
|
||||
const shareToken = await getShareToken("rating_topic");
|
||||
getShareReward({ scene: "rating_topic" });
|
||||
const topName = ratingItems.value?.[0]?.name;
|
||||
return {
|
||||
title: topName
|
||||
? `「${topicData.value.title || "夯拉评分"}」正在热评,${topName}暂时领先`
|
||||
: `「${topicData.value.title || "夯拉评分"}」正在热评,快来参与打分`,
|
||||
path: `/pages/rating/index?topicId=${currentTopicId.value}&shareToken=${shareToken}`,
|
||||
};
|
||||
});
|
||||
|
||||
onShareTimeline(async () => {
|
||||
const shareToken = await getShareToken("rating_topic");
|
||||
getShareReward({ scene: "rating_topic_timeline" });
|
||||
const topName = ratingItems.value?.[0]?.name;
|
||||
return {
|
||||
title: topName
|
||||
? `${topName}在「${topicData.value.title || "夯拉评分"}」里冲到第一了`
|
||||
: `「${topicData.value.title || "夯拉评分"}」榜单已开启,来看看大家怎么评`,
|
||||
query: `topicId=${currentTopicId.value}&shareToken=${shareToken}`,
|
||||
};
|
||||
});
|
||||
|
||||
// 下拉刷新
|
||||
onPullDownRefresh(async () => {
|
||||
if (currentTopicId.value) {
|
||||
@@ -335,11 +466,7 @@ onPullDownRefresh(async () => {
|
||||
loadItems(currentTopicId.value, currentPage.value)
|
||||
]);
|
||||
}
|
||||
// 模拟评论刷新(此处未接接口,暂时保留原有模拟刷新逻辑,去掉 mockRatingItems 的重置)
|
||||
setTimeout(() => {
|
||||
comments.value = [...mockComments];
|
||||
uni.stopPullDownRefresh();
|
||||
}, 1000);
|
||||
uni.stopPullDownRefresh();
|
||||
});
|
||||
|
||||
// 上拉加载更多
|
||||
@@ -349,12 +476,6 @@ onReachBottom(() => {
|
||||
currentPage.value += 1;
|
||||
loadItems(currentTopicId.value, currentPage.value);
|
||||
}
|
||||
|
||||
// 原有的评论模拟加载(根据需要决定是否保留)
|
||||
setTimeout(() => {
|
||||
const moreComments = mockComments.map(c => ({...c, id: c.id + comments.value.length}));
|
||||
comments.value.push(...moreComments);
|
||||
}, 1000);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -400,7 +521,6 @@ onReachBottom(() => {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #001f3f, #0088a9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -443,12 +563,15 @@ onReachBottom(() => {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-icon.fire-icon {
|
||||
.stat-icon {
|
||||
width: 26rpx;
|
||||
height: 26rpx;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23ff4d4f'%3E%3Cpath d='M19.48 13.03c-.02-.19-.13-.36-.29-.46-.17-.11-.38-.13-.56-.05-.98.42-2.12.3-2.92-.31-1.07-.81-1.55-2.22-1.28-3.75.12-.66.1-1.32-.06-1.97-.24-.95-1.1-1.6-2.09-1.58-1 .02-1.83.74-2 1.72-.25 1.55-.95 2.94-2 4.02-1.38 1.41-2.05 3.39-1.85 5.4.2 2.05 1.42 3.86 3.25 4.82 1.37.72 2.94 1.01 4.5.83 2.74-.32 5.06-2.28 5.86-4.95.23-.75.29-1.52.19-2.28-.06-.5-.21-.99-.41-1.45l-.34-.99zm-4.98 6.47c-1.37 0-2.5-1.13-2.5-2.5s1.13-2.5 2.5-2.5 2.5 1.13 2.5 2.5-1.13 2.5-2.5 2.5z'/%3E%3C/svg%3E");
|
||||
background-size: cover;
|
||||
margin-right: 12rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-icon-gap {
|
||||
margin-left: 32rpx;
|
||||
}
|
||||
|
||||
.stat-text {
|
||||
@@ -457,8 +580,7 @@ onReachBottom(() => {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-text.margin-left {
|
||||
margin-left: 32rpx;
|
||||
.stat-text-participant {
|
||||
color: #555;
|
||||
font-weight: normal;
|
||||
}
|
||||
@@ -482,11 +604,13 @@ onReachBottom(() => {
|
||||
.ai-icon {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%234d44f1'%3E%3Cpath d='M12 2l2.4 5.6L20 10l-5.6 2.4L12 18l-2.4-5.6L4 10l5.6-2.4L12 2zm0 0'/%3E%3C/svg%3E");
|
||||
background-size: cover;
|
||||
margin-right: 12rpx;
|
||||
}
|
||||
|
||||
.ai-icon-img {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ai-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
@@ -499,6 +623,103 @@ onReachBottom(() => {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.pk-entry-card {
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f7f4ff 52%, #eef1ff 100%);
|
||||
border: 2rpx solid rgba(99, 88, 255, 0.14);
|
||||
border-radius: 28rpx;
|
||||
padding: 28rpx;
|
||||
margin-bottom: 42rpx;
|
||||
box-shadow: 0 12rpx 30rpx rgba(91, 84, 223, 0.08);
|
||||
}
|
||||
|
||||
.pk-entry-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.pk-entry-title {
|
||||
display: block;
|
||||
font-size: 32rpx;
|
||||
font-weight: 800;
|
||||
color: #1d2233;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.pk-entry-subtitle {
|
||||
font-size: 24rpx;
|
||||
color: #7d8495;
|
||||
}
|
||||
|
||||
.pk-entry-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #5c43f5;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pk-entry-arrow {
|
||||
width: 24rpx;
|
||||
height: 24rpx;
|
||||
margin-left: 6rpx;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%235c43f5' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='9 18 15 12 9 6'%3E%3C/polyline%3E%3C/svg%3E");
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.pk-entry-battle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.pk-entry-user {
|
||||
width: 220rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pk-entry-avatar {
|
||||
width: 104rpx;
|
||||
height: 104rpx;
|
||||
border-radius: 50%;
|
||||
border: 4rpx solid #fff;
|
||||
box-shadow: 0 8rpx 20rpx rgba(0, 0, 0, 0.08);
|
||||
margin-bottom: 14rpx;
|
||||
}
|
||||
|
||||
.pk-entry-user.left .pk-entry-avatar {
|
||||
border-color: rgba(92, 67, 245, 0.22);
|
||||
}
|
||||
|
||||
.pk-entry-user.right .pk-entry-avatar {
|
||||
border-color: rgba(255, 154, 76, 0.22);
|
||||
}
|
||||
|
||||
.pk-entry-name {
|
||||
font-size: 26rpx;
|
||||
color: #2a2f3c;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pk-entry-vs {
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #6f5aff, #4d44f1);
|
||||
color: #fff;
|
||||
font-size: 30rpx;
|
||||
font-weight: 900;
|
||||
letter-spacing: 1rpx;
|
||||
box-shadow: 0 14rpx 28rpx rgba(92, 67, 245, 0.22);
|
||||
}
|
||||
|
||||
/* 领奖台 (Top 3) */
|
||||
.podium-section {
|
||||
display: flex;
|
||||
|
||||
643
pages/rating/pk.vue
Normal file
@@ -0,0 +1,643 @@
|
||||
<template>
|
||||
<view class="page-container">
|
||||
<view class="nav-bar" :style="{ paddingTop: statusBarHeight + 'px' }">
|
||||
<view class="nav-content">
|
||||
<view class="nav-back" @tap="goBack">
|
||||
<text class="back-icon"></text>
|
||||
</view>
|
||||
<text class="nav-title">PK 对决</text>
|
||||
<view class="nav-placeholder"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view :style="{ height: statusBarHeight + 44 + 'px' }"></view>
|
||||
|
||||
<view class="content-wrap" v-if="leftItem && rightItem">
|
||||
<view class="hero-card">
|
||||
<text class="hero-badge">Battle Mode</text>
|
||||
<text class="hero-title">{{ topicTitle || '夯拉评分 PK 对决' }}</text>
|
||||
<text class="hero-subtitle">你更看好谁?快速站队,感受榜单碰撞的爽感</text>
|
||||
</view>
|
||||
|
||||
<view class="battle-card">
|
||||
<view class="battle-arena">
|
||||
<view class="fighter-card left" :class="{ selected: selectedSide === 'left' }">
|
||||
<image class="fighter-avatar" :src="leftItem.avatar" mode="aspectFill" />
|
||||
<text class="fighter-name">{{ leftItem.name }}</text>
|
||||
<text class="fighter-score">{{ leftItem.scoreText }}</text>
|
||||
</view>
|
||||
|
||||
<view class="battle-center">
|
||||
<view class="vs-badge">VS</view>
|
||||
<text class="battle-tip">{{ scoreGapText }}</text>
|
||||
</view>
|
||||
|
||||
<view class="fighter-card right" :class="{ selected: selectedSide === 'right' }">
|
||||
<image class="fighter-avatar" :src="rightItem.avatar" mode="aspectFill" />
|
||||
<text class="fighter-name">{{ rightItem.name }}</text>
|
||||
<text class="fighter-score">{{ rightItem.scoreText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="support-bar">
|
||||
<view class="support-fill left-fill" :style="{ width: `${supportData.leftPercent}%` }"></view>
|
||||
<view class="support-fill right-fill" :style="{ width: `${supportData.rightPercent}%` }"></view>
|
||||
</view>
|
||||
|
||||
<view class="support-text">
|
||||
<text class="left-text">{{ supportData.leftPercent }}% 看好 {{ leftItem.name }}</text>
|
||||
<text class="right-text">{{ supportData.rightPercent }}% 看好 {{ rightItem.name }}</text>
|
||||
</view>
|
||||
|
||||
<view class="action-row">
|
||||
<button class="support-btn left-btn" @tap="handleSupport('left')">我站 {{ leftItem.name }}</button>
|
||||
<button class="support-btn right-btn" @tap="handleSupport('right')">我站 {{ rightItem.name }}</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="insight-card">
|
||||
<view class="insight-header">
|
||||
<image class="insight-icon" src="/static/images/icon/robot.png" mode="aspectFit"></image>
|
||||
<text class="insight-title">AI PK 分析</text>
|
||||
</view>
|
||||
<text class="insight-content">{{ battleInsight }}</text>
|
||||
</view>
|
||||
|
||||
<view class="rivals-card" v-if="rivalOptions.length">
|
||||
<view class="rivals-header">
|
||||
<text class="rivals-title">换个对手继续 PK</text>
|
||||
<text class="random-action" @tap="randomPickRival">随机换人</text>
|
||||
</view>
|
||||
<scroll-view scroll-x class="rivals-scroll" :show-scrollbar="false">
|
||||
<view class="rivals-list">
|
||||
<view
|
||||
v-for="item in rivalOptions"
|
||||
:key="item.id"
|
||||
class="rival-chip"
|
||||
:class="{ active: String(item.id) === String(rightItem.id) }"
|
||||
@tap="changeRival(item.id)"
|
||||
>
|
||||
<image class="rival-avatar" :src="item.avatar" mode="aspectFill" />
|
||||
<text class="rival-name">{{ item.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="empty-wrap">
|
||||
<text class="empty-title">暂时还无法开启 PK</text>
|
||||
<text class="empty-desc">至少需要两个评分对象,快去为榜单补充更多角色吧</text>
|
||||
</view>
|
||||
|
||||
<LoginPopup ref="loginPopupRef" @logind="handleLoginSuccess" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShareAppMessage, onShareTimeline } from "@dcloudio/uni-app";
|
||||
import { getStatusBarHeight } from "@/utils/system";
|
||||
import { fetchTopicDetail, fetchTopicRatingItems } from "@/api/topic";
|
||||
import { FILE_BASE_URL } from "@/utils/constants";
|
||||
import LoginPopup from "@/components/LoginPopup/LoginPopup.vue";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
import { getShareReward } from "@/api/system";
|
||||
import { getShareToken } from "@/utils/common";
|
||||
|
||||
const userStore = useUserStore();
|
||||
const statusBarHeight = ref(getStatusBarHeight() || 44);
|
||||
const loginPopupRef = ref(null);
|
||||
const topicId = ref("");
|
||||
const focusItemId = ref("");
|
||||
const topicTitle = ref("");
|
||||
const items = ref([]);
|
||||
const leftItemId = ref("");
|
||||
const rightItemId = ref("");
|
||||
const selectedSide = ref("");
|
||||
const isLoggedIn = computed(() => !!userStore.token || !!userStore.userInfo?.nickName);
|
||||
|
||||
const buildAvatarUrl = (avatar) => {
|
||||
if (!avatar) return "https://api.dicebear.com/7.x/avataaars/svg?seed=fallback";
|
||||
return String(avatar).startsWith("http") ? avatar : `${FILE_BASE_URL}${avatar}`;
|
||||
};
|
||||
|
||||
const normalizeItem = (item) => ({
|
||||
...item,
|
||||
avatar: buildAvatarUrl(item.avatarUrl),
|
||||
score: Number(item.scoreAvg || 0),
|
||||
scoreText: `${Number(item.scoreAvg || 0).toFixed(1)} 分`,
|
||||
});
|
||||
|
||||
const sortedItems = computed(() => [...items.value].sort((a, b) => b.score - a.score));
|
||||
const leftItem = computed(() => sortedItems.value.find((item) => String(item.id) === String(leftItemId.value)) || null);
|
||||
const rightItem = computed(() => sortedItems.value.find((item) => String(item.id) === String(rightItemId.value)) || null);
|
||||
|
||||
const calcPercent = (leftScore, rightScore) => {
|
||||
const total = leftScore + rightScore;
|
||||
if (!total) return { leftPercent: 50, rightPercent: 50 };
|
||||
let leftPercent = Math.round((leftScore / total) * 100);
|
||||
leftPercent = Math.max(18, Math.min(82, leftPercent));
|
||||
if (selectedSide.value === "left") {
|
||||
leftPercent = Math.min(88, leftPercent + 4);
|
||||
} else if (selectedSide.value === "right") {
|
||||
leftPercent = Math.max(12, leftPercent - 4);
|
||||
}
|
||||
return {
|
||||
leftPercent,
|
||||
rightPercent: 100 - leftPercent,
|
||||
};
|
||||
};
|
||||
|
||||
const supportData = computed(() => {
|
||||
if (!leftItem.value || !rightItem.value) {
|
||||
return { leftPercent: 50, rightPercent: 50 };
|
||||
}
|
||||
return calcPercent(leftItem.value.score, rightItem.value.score);
|
||||
});
|
||||
|
||||
const scoreGapText = computed(() => {
|
||||
if (!leftItem.value || !rightItem.value) return "火热对决";
|
||||
const gap = Math.abs(leftItem.value.score - rightItem.value.score).toFixed(1);
|
||||
if (gap === "0.0") return "势均力敌";
|
||||
return `分差 ${gap}`;
|
||||
});
|
||||
|
||||
const battleInsight = computed(() => {
|
||||
if (!leftItem.value || !rightItem.value) return "当前榜单对象不足,暂时无法生成 PK 分析。";
|
||||
if (leftItem.value.score === rightItem.value.score) {
|
||||
return `${leftItem.value.name} 和 ${rightItem.value.name} 当前分数持平,这会是一场纯主观审美的大混战。`;
|
||||
}
|
||||
const leader = leftItem.value.score > rightItem.value.score ? leftItem.value : rightItem.value;
|
||||
const follower = leader.id === leftItem.value.id ? rightItem.value : leftItem.value;
|
||||
const gap = Math.abs(leftItem.value.score - rightItem.value.score).toFixed(1);
|
||||
return `${leader.name} 目前在榜单分数上领先 ${gap} 分,但 ${follower.name} 更容易在 PK 模式里制造反转,适合拉好友一起站队。`;
|
||||
});
|
||||
|
||||
const rivalOptions = computed(() => {
|
||||
if (!leftItem.value) return [];
|
||||
return sortedItems.value.filter((item) => String(item.id) !== String(leftItem.value.id));
|
||||
});
|
||||
|
||||
const openLoginPopup = (message = "请先登录后再操作") => {
|
||||
loginPopupRef.value?.open();
|
||||
if (message) {
|
||||
uni.showToast({ title: message, icon: "none" });
|
||||
}
|
||||
};
|
||||
|
||||
const initBattle = () => {
|
||||
if (sortedItems.value.length < 2) {
|
||||
leftItemId.value = "";
|
||||
rightItemId.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIndex = sortedItems.value.findIndex((item) => String(item.id) === String(focusItemId.value));
|
||||
const primary = currentIndex >= 0 ? sortedItems.value[currentIndex] : sortedItems.value[0];
|
||||
const rival =
|
||||
sortedItems.value[currentIndex > 0 ? currentIndex - 1 : 1] ||
|
||||
sortedItems.value.find((item) => String(item.id) !== String(primary.id));
|
||||
|
||||
leftItemId.value = primary?.id || "";
|
||||
rightItemId.value = rival?.id || "";
|
||||
};
|
||||
|
||||
const loadPageData = async () => {
|
||||
if (!topicId.value) return;
|
||||
try {
|
||||
const [detailRes, itemsRes] = await Promise.all([
|
||||
fetchTopicDetail(topicId.value),
|
||||
fetchTopicRatingItems(topicId.value, 1),
|
||||
]);
|
||||
const detail = detailRes?.data || detailRes || {};
|
||||
topicTitle.value = detail.title || "";
|
||||
const rawList = itemsRes?.data?.records || itemsRes?.records || itemsRes?.data?.list || itemsRes?.list || [];
|
||||
items.value = (Array.isArray(rawList) ? rawList : []).map(normalizeItem);
|
||||
initBattle();
|
||||
} catch (error) {
|
||||
items.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
uni.navigateBack({ delta: 1 });
|
||||
};
|
||||
|
||||
const changeRival = (id) => {
|
||||
if (String(id) === String(leftItemId.value)) return;
|
||||
rightItemId.value = id;
|
||||
selectedSide.value = "";
|
||||
};
|
||||
|
||||
const randomPickRival = () => {
|
||||
if (!rivalOptions.value.length) return;
|
||||
const pool = rivalOptions.value.filter((item) => String(item.id) !== String(rightItemId.value));
|
||||
const targetPool = pool.length ? pool : rivalOptions.value;
|
||||
const index = Math.floor(Math.random() * targetPool.length);
|
||||
changeRival(targetPool[index].id);
|
||||
};
|
||||
|
||||
const handleSupport = (side) => {
|
||||
if (!isLoggedIn.value) {
|
||||
openLoginPopup("请先登录后再站队");
|
||||
return;
|
||||
}
|
||||
selectedSide.value = side;
|
||||
const targetName = side === "left" ? leftItem.value?.name : rightItem.value?.name;
|
||||
uni.showToast({
|
||||
title: `已站队 ${targetName}`,
|
||||
icon: "success",
|
||||
});
|
||||
};
|
||||
|
||||
const handleLoginSuccess = () => {};
|
||||
|
||||
onLoad((options) => {
|
||||
topicId.value = options.topicId || "";
|
||||
focusItemId.value = options.itemId || "";
|
||||
loadPageData();
|
||||
});
|
||||
|
||||
onShareAppMessage(async () => {
|
||||
const shareToken = await getShareToken("rating_pk");
|
||||
getShareReward({ scene: "rating_pk" });
|
||||
return {
|
||||
title: leftItem.value && rightItem.value
|
||||
? `你更站 ${leftItem.value.name} 还是 ${rightItem.value.name}?`
|
||||
: `来夯拉评分看看这场 PK 谁会赢`,
|
||||
path: `/pages/rating/pk?topicId=${topicId.value}&itemId=${focusItemId.value}&shareToken=${shareToken}`,
|
||||
};
|
||||
});
|
||||
|
||||
onShareTimeline(async () => {
|
||||
const shareToken = await getShareToken("rating_pk");
|
||||
getShareReward({ scene: "rating_pk_timeline" });
|
||||
return {
|
||||
title: leftItem.value && rightItem.value
|
||||
? `${leftItem.value.name} VS ${rightItem.value.name},你站谁?`
|
||||
: `夯拉评分 PK 模式已开启,快来站队`,
|
||||
query: `topicId=${topicId.value}&itemId=${focusItemId.value}&shareToken=${shareToken}`,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-container {
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(108, 92, 231, 0.16), transparent 32%),
|
||||
linear-gradient(180deg, #f7f8ff 0%, #f8f9fe 38%, #ffffff 100%);
|
||||
padding-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.nav-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
background: rgba(247, 248, 255, 0.84);
|
||||
backdrop-filter: blur(22rpx);
|
||||
}
|
||||
|
||||
.nav-content {
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 32rpx;
|
||||
}
|
||||
|
||||
.nav-back,
|
||||
.nav-placeholder {
|
||||
width: 88rpx;
|
||||
}
|
||||
|
||||
.back-icon {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23333' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='15 18 9 12 15 6'%3E%3C/polyline%3E%3C/svg%3E");
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 800;
|
||||
color: #262b37;
|
||||
}
|
||||
|
||||
.content-wrap {
|
||||
padding: 24rpx 32rpx;
|
||||
}
|
||||
|
||||
.hero-card,
|
||||
.battle-card,
|
||||
.insight-card,
|
||||
.rivals-card {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 2rpx solid rgba(229, 232, 246, 0.9);
|
||||
border-radius: 32rpx;
|
||||
box-shadow: 0 18rpx 40rpx rgba(88, 93, 146, 0.08);
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
padding: 32rpx;
|
||||
}
|
||||
|
||||
.hero-badge {
|
||||
display: inline-flex;
|
||||
height: 44rpx;
|
||||
align-items: center;
|
||||
padding: 0 16rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(92, 67, 245, 0.1);
|
||||
color: #5c43f5;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
margin-bottom: 18rpx;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
display: block;
|
||||
font-size: 42rpx;
|
||||
color: #1e2230;
|
||||
font-weight: 800;
|
||||
line-height: 1.35;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 26rpx;
|
||||
color: #747b8c;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.battle-card {
|
||||
padding: 32rpx 28rpx;
|
||||
}
|
||||
|
||||
.battle-arena {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
|
||||
.fighter-card {
|
||||
width: 240rpx;
|
||||
min-height: 300rpx;
|
||||
padding: 28rpx 20rpx;
|
||||
border-radius: 28rpx;
|
||||
background: linear-gradient(180deg, #f8f9ff 0%, #ffffff 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
border: 2rpx solid transparent;
|
||||
}
|
||||
|
||||
.fighter-card.left {
|
||||
border-color: rgba(92, 67, 245, 0.18);
|
||||
}
|
||||
|
||||
.fighter-card.right {
|
||||
border-color: rgba(255, 168, 92, 0.2);
|
||||
}
|
||||
|
||||
.fighter-card.selected {
|
||||
box-shadow: 0 16rpx 30rpx rgba(92, 67, 245, 0.14);
|
||||
transform: translateY(-4rpx);
|
||||
}
|
||||
|
||||
.fighter-avatar {
|
||||
width: 128rpx;
|
||||
height: 128rpx;
|
||||
border-radius: 50%;
|
||||
border: 6rpx solid #fff;
|
||||
box-shadow: 0 8rpx 20rpx rgba(0, 0, 0, 0.08);
|
||||
margin-bottom: 18rpx;
|
||||
}
|
||||
|
||||
.fighter-name {
|
||||
font-size: 30rpx;
|
||||
color: #202433;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.fighter-score {
|
||||
font-size: 24rpx;
|
||||
color: #81889b;
|
||||
}
|
||||
|
||||
.battle-center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.vs-badge {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #715eff, #4d44f1);
|
||||
color: #fff;
|
||||
font-size: 32rpx;
|
||||
font-weight: 900;
|
||||
letter-spacing: 1rpx;
|
||||
box-shadow: 0 18rpx 32rpx rgba(92, 67, 245, 0.25);
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.battle-tip {
|
||||
font-size: 22rpx;
|
||||
color: #81889b;
|
||||
}
|
||||
|
||||
.support-bar {
|
||||
height: 18rpx;
|
||||
border-radius: 999rpx;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
margin-bottom: 16rpx;
|
||||
background: #eef1fb;
|
||||
}
|
||||
|
||||
.support-fill {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.left-fill {
|
||||
background: linear-gradient(90deg, #5f56f4, #7a71ff);
|
||||
}
|
||||
|
||||
.right-fill {
|
||||
background: linear-gradient(90deg, #ffb056, #ff8d5f);
|
||||
}
|
||||
|
||||
.support-text {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
|
||||
.left-text {
|
||||
color: #5c43f5;
|
||||
}
|
||||
|
||||
.right-text {
|
||||
color: #ff8d5f;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
.support-btn {
|
||||
flex: 1;
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.support-btn::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.left-btn {
|
||||
background: linear-gradient(135deg, #6556ff, #4d44f1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.right-btn {
|
||||
background: linear-gradient(135deg, #ffb15f, #ff8d5f);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.insight-card {
|
||||
padding: 28rpx;
|
||||
}
|
||||
|
||||
.insight-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.insight-icon {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
margin-right: 12rpx;
|
||||
}
|
||||
|
||||
.insight-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 800;
|
||||
color: #4d44f1;
|
||||
}
|
||||
|
||||
.insight-content {
|
||||
font-size: 26rpx;
|
||||
color: #4b5161;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.rivals-card {
|
||||
padding: 28rpx;
|
||||
}
|
||||
|
||||
.rivals-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.rivals-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 800;
|
||||
color: #212533;
|
||||
}
|
||||
|
||||
.random-action {
|
||||
font-size: 24rpx;
|
||||
color: #5c43f5;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rivals-scroll {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rivals-list {
|
||||
display: inline-flex;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
.rival-chip {
|
||||
width: 176rpx;
|
||||
padding: 18rpx 16rpx;
|
||||
border-radius: 24rpx;
|
||||
background: #f7f8fd;
|
||||
border: 2rpx solid transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rival-chip.active {
|
||||
background: #f1efff;
|
||||
border-color: rgba(92, 67, 245, 0.18);
|
||||
}
|
||||
|
||||
.rival-avatar {
|
||||
width: 76rpx;
|
||||
height: 76rpx;
|
||||
border-radius: 50%;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.rival-name {
|
||||
font-size: 24rpx;
|
||||
color: #3a3f4d;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-wrap {
|
||||
min-height: 60vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 0 56rpx;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 34rpx;
|
||||
color: #232733;
|
||||
font-weight: 800;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.empty-desc {
|
||||
font-size: 26rpx;
|
||||
color: #81889b;
|
||||
line-height: 1.7;
|
||||
}
|
||||
</style>
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
<view class="content-wrap">
|
||||
<view class="topic-editor card-block">
|
||||
<view class="editor-badge">创建新话题</view>
|
||||
<textarea
|
||||
v-model="formData.title"
|
||||
class="topic-input"
|
||||
@@ -24,14 +25,7 @@
|
||||
placeholder-class="topic-placeholder"
|
||||
auto-height
|
||||
/>
|
||||
<!-- <textarea
|
||||
v-model="formData.description"
|
||||
class="desc-input"
|
||||
maxlength="120"
|
||||
placeholder="简单介绍一下这个评分话题..."
|
||||
placeholder-class="desc-placeholder"
|
||||
auto-height
|
||||
/> -->
|
||||
<text class="topic-helper">标题越明确,越容易吸引大家参与评分和评论</text>
|
||||
</view>
|
||||
|
||||
<!-- <view class="cover-card" :class="{ 'has-cover': formData.coverUrl }" @tap="chooseCover">
|
||||
@@ -47,20 +41,29 @@
|
||||
</view>
|
||||
</view> -->
|
||||
|
||||
<view class="category-group">
|
||||
<view
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
class="category-chip"
|
||||
:class="{ active: formData.categoryId === category.id }"
|
||||
@tap="formData.categoryId = category.id"
|
||||
>
|
||||
{{ category.title }}
|
||||
<view class="category-group card-block">
|
||||
<view class="section-meta">
|
||||
<text class="category-label">话题分类</text>
|
||||
<text class="section-helper">选择一个更贴近内容的圈层</text>
|
||||
</view>
|
||||
<view class="category-list">
|
||||
<view
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
class="category-chip"
|
||||
:class="{ active: formData.categoryId === category.id }"
|
||||
@tap="formData.categoryId = category.id"
|
||||
>
|
||||
{{ category.title }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section-header">
|
||||
<text class="section-title">评分对象</text>
|
||||
<view class="section-title-wrap">
|
||||
<text class="section-title">评分对象</text>
|
||||
<text class="section-subtitle">至少保留 1 个,最多添加 20 个</text>
|
||||
</view>
|
||||
<text class="section-count">{{ participants.length }}/20</text>
|
||||
</view>
|
||||
|
||||
@@ -101,17 +104,19 @@
|
||||
</view>
|
||||
<view class="participant-actions">
|
||||
<!-- <text class="drag-icon">≡</text> -->
|
||||
<text class="delete-icon" @tap="removeParticipant(item.id)">🗑</text>
|
||||
<image class="delete-icon-img" src="/static/images/icon/remove.png" @tap="removeParticipant(item.id)" mode="aspectFit"></image>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="add-participant" @tap="addParticipant">
|
||||
<text class="add-plus">+</text>
|
||||
<view class="add-plus-wrap">
|
||||
<text class="add-plus">+</text>
|
||||
</view>
|
||||
<text class="add-text">添加评分对象</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="settings-card card-block">
|
||||
<!-- <view class="settings-card card-block">
|
||||
<view class="setting-row">
|
||||
<view class="setting-info">
|
||||
<text class="setting-title">允许用户锐评</text>
|
||||
@@ -147,17 +152,21 @@
|
||||
@change="formData.anonymous = $event.detail.value"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view> -->
|
||||
</view>
|
||||
|
||||
<view class="footer-bar">
|
||||
<button class="submit-btn" @tap="handleSubmit">发布全民评分</button>
|
||||
<button class="submit-btn" @tap="handleSubmit">发布夯拉评分</button>
|
||||
</view>
|
||||
|
||||
<!-- 成功特效弹窗 -->
|
||||
<SuccessPopup ref="successPopupRef" />
|
||||
<LoginPopup ref="loginPopupRef" @logind="handleLoginSuccess" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from "vue";
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { getStatusBarHeight } from "@/utils/system";
|
||||
import { releaseTopic, fetchTopicCategories } from "@/api/topic";
|
||||
@@ -166,9 +175,16 @@ import {
|
||||
uploadImage,
|
||||
chooseAndUploadImage,
|
||||
} from "@/utils/common.js";
|
||||
import SuccessPopup from "@/components/SuccessPopup/SuccessPopup.vue";
|
||||
import LoginPopup from "@/components/LoginPopup/LoginPopup.vue";
|
||||
import { FILE_BASE_URL } from "@/utils/constants";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
|
||||
const userStore = useUserStore();
|
||||
const successPopupRef = ref(null);
|
||||
const loginPopupRef = ref(null);
|
||||
const statusBarHeight = ref(getStatusBarHeight() || 0);
|
||||
const isLoggedIn = computed(() => !!userStore.token || !!userStore.userInfo?.nickName);
|
||||
|
||||
const categories = ref([]);
|
||||
|
||||
@@ -219,7 +235,21 @@ const goBack = () => {
|
||||
smartNavigateBack();
|
||||
};
|
||||
|
||||
const openLoginPopup = (message = "请先登录后再操作") => {
|
||||
loginPopupRef.value?.open();
|
||||
if (message) {
|
||||
uni.showToast({
|
||||
title: message,
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreview = () => {
|
||||
if (!isLoggedIn.value) {
|
||||
openLoginPopup("请先登录后再预览");
|
||||
return;
|
||||
}
|
||||
uni.showToast({
|
||||
title: "预览功能待接入",
|
||||
icon: "none",
|
||||
@@ -238,6 +268,10 @@ const chooseCover = async () => {
|
||||
};
|
||||
|
||||
const chooseParticipantAvatar = async (index) => {
|
||||
if (!isLoggedIn.value) {
|
||||
openLoginPopup("请先登录后再上传头像");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const urls = await chooseAndUploadImage({ count: 1 });
|
||||
if (urls && urls.length > 0) {
|
||||
@@ -294,6 +328,10 @@ const removeParticipant = (id) => {
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!isLoggedIn.value) {
|
||||
openLoginPopup("请先登录后再发起评分");
|
||||
return;
|
||||
}
|
||||
if (!formData.title.trim()) {
|
||||
uni.showToast({ title: '请输入话题', icon: 'none' });
|
||||
return;
|
||||
@@ -321,14 +359,44 @@ const handleSubmit = async () => {
|
||||
participants: participants.value,
|
||||
};
|
||||
await releaseTopic(payload);
|
||||
|
||||
uni.hideLoading();
|
||||
uni.showToast({
|
||||
title: "发布成功",
|
||||
icon: "success",
|
||||
|
||||
// 记录发布事件
|
||||
uni.$trackRecord({
|
||||
eventName: 'release_topic',
|
||||
eventType: 'release',
|
||||
elementContent: `话题_${payload.title}`,
|
||||
customParams: {
|
||||
title: payload.title,
|
||||
categoryId: payload.categoryId,
|
||||
}
|
||||
});
|
||||
|
||||
smartNavigateBack({ delay: 1500 });
|
||||
uni.hideLoading();
|
||||
|
||||
if (successPopupRef.value) {
|
||||
successPopupRef.value.show({
|
||||
emoji: '🎉',
|
||||
label: '请耐心等待管理员审核'
|
||||
}, 'release');
|
||||
}
|
||||
|
||||
// 初始化表单内容
|
||||
Object.assign(formData, {
|
||||
title: "",
|
||||
description: "",
|
||||
coverUrl: "",
|
||||
categoryId: "",
|
||||
});
|
||||
|
||||
// 初始化评分对象列表为默认的一个空项,并且赋予唯一的 ID
|
||||
participants.value = [
|
||||
{ id: Date.now(), name: "", desc: "", avatar: "" }
|
||||
];
|
||||
|
||||
// 延迟跳转,等待弹窗展示完成
|
||||
setTimeout(() => {
|
||||
smartNavigateBack();
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
uni.hideLoading();
|
||||
uni.showToast({
|
||||
@@ -338,14 +406,23 @@ const handleSubmit = async () => {
|
||||
console.error("发布失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoginSuccess = () => {
|
||||
if (categories.value.length === 0) {
|
||||
loadCategories();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-container {
|
||||
min-height: 100vh;
|
||||
background: #f8f9fc; /* 整体背景调亮 */
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(111, 90, 255, 0.1), transparent 34%),
|
||||
linear-gradient(180deg, #f7f8ff 0%, #f6f7fb 28%, #ffffff 100%);
|
||||
padding-bottom: 180rpx;
|
||||
box-sizing: border-box;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", Helvetica, Segoe UI, Arial, Roboto, "PingFang SC", "miui", "Hiragino Sans GB", "Microsoft Yahei", sans-serif;
|
||||
}
|
||||
|
||||
.nav-bar {
|
||||
@@ -354,8 +431,9 @@ const handleSubmit = async () => {
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
background: rgba(244, 245, 251, 0.96);
|
||||
backdrop-filter: blur(18rpx);
|
||||
background: rgba(247, 248, 255, 0.82);
|
||||
backdrop-filter: blur(24rpx);
|
||||
border-bottom: 1rpx solid rgba(133, 142, 194, 0.08);
|
||||
}
|
||||
|
||||
.nav-content {
|
||||
@@ -363,7 +441,7 @@ const handleSubmit = async () => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24rpx;
|
||||
padding: 0 28rpx;
|
||||
}
|
||||
|
||||
.nav-side {
|
||||
@@ -385,61 +463,68 @@ const handleSubmit = async () => {
|
||||
|
||||
.nav-title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
color: #171717;
|
||||
font-weight: 800;
|
||||
color: #16161d;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
.preview-text {
|
||||
font-size: 28rpx;
|
||||
color: #5f63ff;
|
||||
color: #5c43f5;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.content-wrap {
|
||||
padding: 24rpx;
|
||||
padding: 28rpx 24rpx 0;
|
||||
}
|
||||
|
||||
/* 统一的卡片风格 */
|
||||
.card-block {
|
||||
background: #ffffff;
|
||||
border-radius: 36rpx; /* 增大圆角,与设计图更统一 */
|
||||
box-shadow: 0 4rpx 20rpx rgba(72, 82, 130, 0.03); /* 柔和的阴影 */
|
||||
margin-bottom: 32rpx; /* 增加块级间距 */
|
||||
border: 2rpx solid #eef0f7; /* 统一的极淡边框 */
|
||||
border-radius: 34rpx;
|
||||
box-shadow: 0 18rpx 40rpx rgba(84, 88, 132, 0.08);
|
||||
margin-bottom: 28rpx;
|
||||
border: 1rpx solid rgba(222, 227, 246, 0.9);
|
||||
}
|
||||
|
||||
.topic-editor {
|
||||
padding: 32rpx; /* 增加内边距 */
|
||||
margin-bottom: 24rpx;
|
||||
padding: 28rpx 28rpx 30rpx;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(245, 246, 255, 0.92));
|
||||
}
|
||||
|
||||
.topic-input,
|
||||
.desc-input {
|
||||
width: 100%;
|
||||
min-height: 52rpx;
|
||||
color: #232323;
|
||||
.editor-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 44rpx;
|
||||
padding: 0 18rpx;
|
||||
border-radius: 999rpx;
|
||||
margin-bottom: 22rpx;
|
||||
background: rgba(92, 67, 245, 0.08);
|
||||
color: #5c43f5;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.topic-input {
|
||||
font-size: 44rpx; /* 稍微减小标题字号,显得更精致 */
|
||||
font-weight: 800; /* 加粗 */
|
||||
margin-bottom: 16rpx; /* 增加行距 */
|
||||
}
|
||||
|
||||
.desc-input {
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
width: 100%;
|
||||
min-height: 76rpx;
|
||||
color: #191a22;
|
||||
font-size: 48rpx;
|
||||
font-weight: 800;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.topic-placeholder {
|
||||
font-size: 44rpx;
|
||||
font-size: 48rpx;
|
||||
font-weight: 800;
|
||||
color: #c1c2d3;
|
||||
color: #c6cadc;
|
||||
}
|
||||
|
||||
.desc-placeholder {
|
||||
font-size: 28rpx;
|
||||
color: #b8b9c9;
|
||||
.topic-helper {
|
||||
margin-top: 18rpx;
|
||||
display: block;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.6;
|
||||
color: #8a8fa7;
|
||||
}
|
||||
|
||||
.cover-card {
|
||||
@@ -483,29 +568,53 @@ const handleSubmit = async () => {
|
||||
}
|
||||
|
||||
.category-group {
|
||||
padding: 28rpx;
|
||||
}
|
||||
|
||||
.section-meta {
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.category-label {
|
||||
display: block;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #1c1d24;
|
||||
}
|
||||
|
||||
.section-helper {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 24rpx;
|
||||
color: #9196ac;
|
||||
}
|
||||
|
||||
.category-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.category-chip {
|
||||
min-width: 88rpx;
|
||||
padding: 14rpx 32rpx; /* 增加内边距 */
|
||||
padding: 16rpx 30rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #e9ecf5; /* 背景调浅一些,更接近设计图 */
|
||||
color: #5c6176; /* 字体颜色调浅 */
|
||||
font-size: 28rpx; /* 字体调大 */
|
||||
font-weight: 500;
|
||||
background: #f3f5fc;
|
||||
color: #5f647d;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
transition: all 0.2s ease;
|
||||
border: 1rpx solid transparent;
|
||||
transition: all 0.22s ease;
|
||||
}
|
||||
|
||||
.category-chip.active {
|
||||
background: linear-gradient(135deg, #6c4af2, #883cf3); /* 更接近设计图的紫色渐变 */
|
||||
background: linear-gradient(135deg, #5e43f4, #7d4dff);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 8rpx 20rpx rgba(108, 74, 242, 0.3); /* 调整阴影颜色 */
|
||||
border-color: rgba(116, 85, 255, 0.24);
|
||||
box-shadow: 0 12rpx 26rpx rgba(101, 74, 245, 0.25);
|
||||
transform: translateY(-2rpx);
|
||||
}
|
||||
|
||||
.tag-box {
|
||||
@@ -555,16 +664,27 @@ const handleSubmit = async () => {
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24rpx; /* 增加底部间距 */
|
||||
padding: 0 8rpx; /* 标题与内容对齐 */
|
||||
margin-bottom: 20rpx;
|
||||
padding: 0 8rpx;
|
||||
}
|
||||
|
||||
.section-title-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 34rpx; /* 调整大小 */
|
||||
font-weight: 800; /* 加粗 */
|
||||
color: #171717;
|
||||
font-size: 34rpx;
|
||||
font-weight: 800;
|
||||
color: #1b1c24;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
margin-top: 8rpx;
|
||||
font-size: 24rpx;
|
||||
color: #9297ad;
|
||||
}
|
||||
|
||||
.section-count {
|
||||
@@ -572,10 +692,11 @@ const handleSubmit = async () => {
|
||||
height: 46rpx;
|
||||
line-height: 46rpx;
|
||||
text-align: center;
|
||||
border-radius: 14rpx;
|
||||
background: #e6e9f7;
|
||||
color: #5c6176;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(92, 67, 245, 0.1);
|
||||
color: #5c43f5;
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.participant-list {
|
||||
@@ -587,11 +708,15 @@ const handleSubmit = async () => {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
border-radius: 36rpx; /* 更圆润的边角 */
|
||||
padding: 32rpx 28rpx; /* 增加内边距 */
|
||||
border-radius: 32rpx;
|
||||
padding: 28rpx 24rpx;
|
||||
margin-bottom: 24rpx;
|
||||
border: 2rpx solid #eef0f7; /* 增加细微的描边,更接近设计图质感 */
|
||||
box-shadow: 0 4rpx 20rpx rgba(72, 82, 130, 0.03); /* 阴影调弱 */
|
||||
border: 1rpx solid #edf0fb;
|
||||
box-shadow: 0 12rpx 28rpx rgba(72, 82, 130, 0.05);
|
||||
}
|
||||
|
||||
.participant-card:active {
|
||||
transform: scale(0.995);
|
||||
}
|
||||
|
||||
.participant-main {
|
||||
@@ -602,21 +727,23 @@ const handleSubmit = async () => {
|
||||
|
||||
.participant-avatar-wrap {
|
||||
margin-right: 20rpx;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.participant-avatar {
|
||||
width: 92rpx;
|
||||
height: 92rpx;
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 50%;
|
||||
background: #e8ebf7;
|
||||
background: #eceffc;
|
||||
box-shadow: 0 8rpx 18rpx rgba(95, 99, 255, 0.12);
|
||||
}
|
||||
|
||||
.participant-avatar.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2rpx dashed #b5bad0; /* 加深虚线颜色 */
|
||||
background: #f8f9fc; /* 空状态背景色 */
|
||||
border: 2rpx dashed #c8cde1;
|
||||
background: linear-gradient(180deg, #fafbff, #f3f5fc);
|
||||
}
|
||||
|
||||
.camera-small {
|
||||
@@ -636,65 +763,64 @@ const handleSubmit = async () => {
|
||||
}
|
||||
|
||||
.participant-name {
|
||||
height: 42rpx;
|
||||
height: 44rpx;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #202020;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.participant-desc {
|
||||
height: 38rpx;
|
||||
font-size: 24rpx;
|
||||
color: #676c7f;
|
||||
color: #1c1d24;
|
||||
}
|
||||
|
||||
.participant-placeholder {
|
||||
color: #b6b9c7;
|
||||
color: #b7bbcd;
|
||||
}
|
||||
|
||||
.participant-actions {
|
||||
width: 56rpx;
|
||||
width: 72rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16rpx;
|
||||
margin-left: 16rpx;
|
||||
}
|
||||
|
||||
.drag-icon {
|
||||
font-size: 34rpx;
|
||||
color: #b2b4c5;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.delete-icon {
|
||||
font-size: 32rpx; /* 图标放大 */
|
||||
color: #ff6b6b; /* 明确的删除红色 */
|
||||
line-height: 1;
|
||||
padding: 8rpx; /* 增加点击区域 */
|
||||
.delete-icon-img {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
padding: 12rpx;
|
||||
border-radius: 50%;
|
||||
background: #fff5f5;
|
||||
}
|
||||
|
||||
.add-participant {
|
||||
height: 120rpx; /* 增加高度 */
|
||||
border: 2rpx dashed #c0c4d6; /* 边框颜色更明显 */
|
||||
border-radius: 36rpx; /* 圆角一致 */
|
||||
height: 120rpx;
|
||||
border: 2rpx dashed #cfd5e8;
|
||||
border-radius: 32rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #888d9e; /* 字体颜色微调 */
|
||||
gap: 16rpx; /* 间距变大 */
|
||||
background: #f8f9fc; /* 添加底色 */
|
||||
color: #737993;
|
||||
gap: 16rpx;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.add-plus-wrap {
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(92, 67, 245, 0.1);
|
||||
}
|
||||
|
||||
.add-plus {
|
||||
font-size: 38rpx;
|
||||
font-size: 34rpx;
|
||||
line-height: 1;
|
||||
color: #5c43f5;
|
||||
}
|
||||
|
||||
.add-text {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
@@ -738,19 +864,20 @@ const handleSubmit = async () => {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 24rpx 24rpx calc(24rpx + env(safe-area-inset-bottom));
|
||||
background: linear-gradient(180deg, rgba(244, 245, 251, 0) 0%, rgba(244, 245, 251, 0.98) 34%);
|
||||
padding: 24rpx 24rpx calc(28rpx + env(safe-area-inset-bottom));
|
||||
background: linear-gradient(180deg, rgba(247, 248, 255, 0) 0%, rgba(247, 248, 255, 0.94) 26%, rgba(247, 248, 255, 1) 100%);
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
height: 100rpx;
|
||||
line-height: 100rpx;
|
||||
height: 104rpx;
|
||||
line-height: 104rpx;
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(135deg, #5c43f5, #7a2cf3); /* 更接近设计图的深紫色 */
|
||||
background: linear-gradient(135deg, #5c43f5, #6f55ff 58%, #8b66ff);
|
||||
color: #fff;
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 16rpx 32rpx rgba(100, 60, 240, 0.3); /* 加强底部按钮阴影 */
|
||||
font-weight: 800;
|
||||
letter-spacing: 1rpx;
|
||||
box-shadow: 0 18rpx 36rpx rgba(98, 69, 246, 0.34);
|
||||
}
|
||||
|
||||
.submit-btn::after {
|
||||
|
||||
BIN
static/images/icon/1.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
static/images/icon/2.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
static/images/icon/3.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
static/images/icon/approved.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
static/images/icon/fire.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
static/images/icon/group.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
static/images/icon/pending.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
static/images/icon/rejected.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
static/images/icon/remove.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
static/images/icon/robot.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
static/images/logo.jpg
Normal file
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 6.7 KiB |
BIN
static/images/tabBar/message.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
static/images/tabBar/message_s.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
static/images/tabBar/pk.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
static/images/tabBar/pk_s.png
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
BIN
static/images/tabBar/publish.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
static/images/tabBar/publish_s.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
@@ -34,6 +34,8 @@ export const useUserStore = defineStore("user", {
|
||||
this.userInfo = {};
|
||||
uni.removeStorageSync("token");
|
||||
uni.removeStorageSync("userInfo");
|
||||
uni.removeTabBarBadge({ index: 3 });
|
||||
uni.hideTabBarRedDot({ index: 3 });
|
||||
},
|
||||
loadUserInfo() {
|
||||
const userStr = uni.getStorageSync("userInfo");
|
||||
@@ -83,6 +85,8 @@ export const useUserStore = defineStore("user", {
|
||||
this.token = "";
|
||||
uni.removeStorageSync("userInfo");
|
||||
uni.removeStorageSync("token");
|
||||
uni.removeTabBarBadge({ index: 3 });
|
||||
uni.hideTabBarRedDot({ index: 3 });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -154,6 +154,15 @@ export const getShareToken = async (scene, targetId = "") => {
|
||||
return shareTokenRes?.shareToken || "";
|
||||
};
|
||||
|
||||
/**
|
||||
* 记录用户行为事件
|
||||
* @param {Object} event 事件对象,包含事件名称和参数
|
||||
* eventName 事件名称,例如 'switchCategory'
|
||||
* eventType 事件类型,例如 'click',可选
|
||||
* elementId 事件元素 ID,例如 'category_123',可选
|
||||
* elementContent 事件元素内容,例如 '分类1',可选
|
||||
* customParams 自定义参数,例如 { categoryId: 123 },可选
|
||||
*/
|
||||
export const trackRecord = (event) => {
|
||||
const pages = getCurrentPages();
|
||||
// 核心修复:在小程序刚启动或页面尚未压栈时,pages 可能为空数组
|
||||
|
||||
65
utils/tabBar.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import { fetchUnreadCount } from "@/api/notification";
|
||||
|
||||
const MESSAGE_TAB_INDEX = 3;
|
||||
|
||||
function parseStoredUserInfo() {
|
||||
const raw = uni.getStorageSync("userInfo");
|
||||
if (!raw) return {};
|
||||
|
||||
try {
|
||||
return typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
} catch (error) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function hasLoginState() {
|
||||
const token = uni.getStorageSync("token");
|
||||
const userInfo = parseStoredUserInfo();
|
||||
return !!token || !!userInfo?.nickName;
|
||||
}
|
||||
|
||||
export function syncMessageTabBarBadge(unreadCount = 0) {
|
||||
const count = Number(unreadCount) || 0;
|
||||
|
||||
if (count > 0) {
|
||||
const text = count > 99 ? "99+" : String(count);
|
||||
uni.setTabBarBadge({
|
||||
index: MESSAGE_TAB_INDEX,
|
||||
text,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
uni.removeTabBarBadge({
|
||||
index: MESSAGE_TAB_INDEX,
|
||||
});
|
||||
uni.hideTabBarRedDot({
|
||||
index: MESSAGE_TAB_INDEX,
|
||||
});
|
||||
}
|
||||
|
||||
export async function refreshMessageTabBarBadge() {
|
||||
if (!hasLoginState()) {
|
||||
syncMessageTabBarBadge(0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetchUnreadCount();
|
||||
const count =
|
||||
Number(
|
||||
res?.data?.count ??
|
||||
res?.data?.unreadCount ??
|
||||
res?.count ??
|
||||
res?.unreadCount ??
|
||||
res?.data ??
|
||||
0
|
||||
) || 0;
|
||||
|
||||
syncMessageTabBarBadge(count);
|
||||
return count;
|
||||
} catch (error) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||