Compare commits

...

31 Commits

Author SHA1 Message Date
zzc
ada774a142 rating 2026-06-01 23:55:03 +08:00
zzc
cbb0cf0ac7 rating 2026-05-27 18:09:15 +08:00
zzc
87784a78c6 rating detail 2026-05-26 22:55:28 +08:00
zzc
b6d55e7698 rating detail 2026-05-26 21:02:06 +08:00
zzc
f295b53f72 fix: rating 2026-05-26 10:48:05 +08:00
zzc
6d42ebb513 fix: rating 2026-05-26 10:25:33 +08:00
zzc
b3f7609494 fix: rating 2026-05-26 10:01:46 +08:00
zzc
02bfefa58c fix: rating 2026-05-26 09:52:58 +08:00
zzc
d9e14b09c6 fix: rating 2026-05-26 09:29:51 +08:00
zzc
7ef0e46cc8 fix: rating 2026-05-26 07:29:52 +08:00
zzc
da6fb97da4 fix: rating 2026-05-26 07:20:05 +08:00
zzc
f5534632ff fix: rating 2026-05-25 00:01:32 +08:00
zzc
8dfe94453b fix: rating 2026-05-24 23:45:47 +08:00
zzc
f8f7d80922 fix: rating 2026-05-24 22:54:35 +08:00
zzc
553d546c6e fix: rating 2026-05-24 00:12:07 +08:00
zzc
1138e1f0de fix: rating 2026-05-23 23:59:54 +08:00
zzc
07a568c878 fix: rating 2026-05-23 23:24:57 +08:00
zzc
0270ba084f feat: release 2026-05-21 23:19:20 +08:00
zzc
1b7dc31916 feat: release 2026-05-21 23:00:54 +08:00
zzc
b778216afb feat: release 2026-05-21 18:00:07 +08:00
zzc
b349e606fc feat: release 2026-05-21 06:56:42 +08:00
zzc
1107d296ce feat: release 2026-05-20 23:32:59 +08:00
zzc
c03a699ab9 feat: release 2026-05-20 23:28:57 +08:00
zzc
48c7cebac1 feat: release 2026-05-20 23:16:53 +08:00
zzc
0143a08af7 feat: release 2026-05-20 15:58:04 +08:00
zzc
7a5afff119 feat: release 2026-05-20 15:49:45 +08:00
zzc
50728c0211 rating detail 2026-05-07 18:38:57 +08:00
zzc
30c62c1b10 rating detail 2026-05-07 17:03:19 +08:00
zzc
84ae386223 rating detail 2026-05-07 15:57:39 +08:00
zzc
d40cc64fa8 style 2026-05-07 15:24:28 +08:00
zzc
e432da9c75 first commit 2026-05-07 11:09:32 +08:00
17 changed files with 3783 additions and 228 deletions

42
api/topic.js Normal file
View File

@@ -0,0 +1,42 @@
import { request } from "@/utils/request.js";
// 发布主题
export const releaseTopic = async (data) => {
return request({
url: "/api/rating/topic/release",
method: "POST",
data,
});
};
// 获取主题分类列表
export const fetchTopicCategories = async (data) => {
return request({
url: "/api/rating/topic/category/list",
method: "GET",
});
};
// 根据分类ID获取主题列表
export const fetchTopicList = async (categoryId, page = 1) => {
return request({
url: `/api/rating/topic/list/${categoryId}?page=${page}`,
method: "GET",
});
};
// 获取主题详情
export const fetchTopicDetail = async (topicId) => {
return request({
url: `/api/rating/topic/detail/${topicId}`,
method: "GET",
});
};
// 获取主题下的评分项
export const fetchTopicRatingItems = async (topicId, page = 1) => {
return request({
url: `/api/rating/topic/item/list/${topicId}?page=${page}`,
method: "GET",
});
};

45
api/topicItem.js Normal file
View File

@@ -0,0 +1,45 @@
import { request } from "@/utils/request.js";
// 打分
export const topicItemScore = async (data) => {
return request({
url: "/api/rating/topic/item/score",
method: "POST",
data,
});
};
// 评论
export const topicItemComment = async (data) => {
return request({
url: "/api/rating/topic/comment/item",
method: "POST",
data,
});
};
// 评论点赞
// data = { commentId: 'xxx' }
export const topicItemCommentLike = async (data) => {
return request({
url: "/api/rating/topic/comment/item/like",
method: "POST",
data,
});
};
// 获取评论列表
export const topicItemCommentList = async (topicId, itemId, page = 1, orderBy = 1) => {
return request({
url: `/api/rating/topic/comment/item/list?topicId=${topicId}&itemId=${itemId}&page=${page}&orderBy=${orderBy}`,
method: "GET",
});
};
// 获取评分项详情
export const fetchTopicRatingItems = async (itemId) => {
return request({
url: `/api/rating/topic/item/detail/${itemId}`,
method: "GET",
});
};

View File

@@ -0,0 +1,96 @@
<template>
<view class="action-badge" :class="badgeClass" v-if="action">
<text class="badge-icon">{{ badgeIcon }}</text>
<text class="badge-text">{{ action }}</text>
</view>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
action: {
type: String,
default: ''
}
});
const badgeClass = computed(() => {
switch (props.action) {
case '夯': return 'badge-god';
case '顶级': return 'badge-good';
case '人上人': return 'badge-ok';
case 'NPC': return 'badge-bad';
case '拉': return 'badge-terrible';
default: return 'badge-default';
}
});
const badgeIcon = computed(() => {
switch (props.action) {
case '夯': return '👑';
case '顶级': return '🔥';
case '人上人': return '🙂';
case 'NPC': return '😐';
case '拉': return '👎';
default: return '';
}
});
</script>
<style lang="scss" scoped>
.action-badge {
display: inline-flex;
align-items: center;
padding: 4rpx 16rpx;
border-radius: 100rpx;
font-size: 20rpx;
font-weight: bold;
margin-left: 12rpx;
}
.badge-icon {
margin-right: 6rpx;
font-size: 20rpx;
}
/* 夯 - 金色/黄色 */
.badge-god {
background: linear-gradient(135deg, #fff8e1, #ffe082);
color: #b07d00;
border: 1rpx solid #ffecb3;
}
/* 顶级 - 红色/橙色 */
.badge-good {
background: linear-gradient(135deg, #ffebee, #ffcdd2);
color: #c62828;
border: 1rpx solid #ffcdd2;
}
/* 人上人 - 蓝色/绿色 */
.badge-ok {
background: linear-gradient(135deg, #e8f5e9, #c8e6c9);
color: #2e7d32;
border: 1rpx solid #c8e6c9;
}
/* NPC - 灰色 */
.badge-bad {
background: linear-gradient(135deg, #f5f5f5, #e0e0e0);
color: #616161;
border: 1rpx solid #e0e0e0;
}
/* 拉 - 深灰/暗红 */
.badge-terrible {
background: linear-gradient(135deg, #eceff1, #cfd8dc);
color: #455a64;
border: 1rpx solid #cfd8dc;
}
.badge-default {
background-color: #f5f6fa;
color: #666;
}
</style>

View File

@@ -0,0 +1,168 @@
<template>
<view class="attitude-panel card">
<text class="section-title">你的态度</text>
<view class="action-group">
<view
class="action-item"
v-for="(action, index) in actions"
:key="index"
:class="{ active: currentAction === action.label }"
@tap="handleAction(action)"
>
<view class="emoji-wrap">
<text class="emoji">{{ action.emoji }}</text>
</view>
<text class="action-name">{{ action.label }}</text>
</view>
</view>
<view class="quick-comment">
<input
class="comment-input"
type="text"
v-model="commentText"
placeholder="说点什么吧..."
placeholder-class="input-placeholder"
/>
<button class="send-btn" @tap="handleSend">发送</button>
</view>
</view>
</template>
<script setup>
import { ref, watch } from 'vue';
const props = defineProps({
initialAction: {
type: String,
default: ''
}
});
const emit = defineEmits(['action-change', 'send-comment']);
const currentAction = ref(props.initialAction);
// 监听父组件传入的 initialAction 的变化
watch(() => props.initialAction, (newVal) => {
currentAction.value = newVal;
});
const commentText = ref('');
const actions = [
{ label: '拉', value: 'terrible', emoji: '👎', score: 2, scoreType:1 },
{ label: 'NPC', value: 'bad', emoji: '😐', score: 4, scoreType: 2},
{ label: '人上人', value: 'ok', emoji: '🙂', score: 6, scoreType: 3},
{ label: '顶级', value: 'good', emoji: '🔥', score: 8, scoreType: 4},
{ label: '夯', value: 'god', emoji: '👑', score: 10, scoreType: 5 }
];
const handleAction = (action) => {
emit('action-change', action);
};
const handleSend = () => {
if (!commentText.value.trim()) {
uni.showToast({
title: '请输入评论内容',
icon: 'none'
});
return;
}
emit('send-comment', {
action: currentAction.value,
content: commentText.value
});
commentText.value = ''; // 发送后清空
};
</script>
<style lang="scss" scoped>
.card {
background-color: #fff;
border-radius: 32rpx;
padding: 32rpx;
margin-bottom: 24rpx;
}
.section-title {
font-size: 34rpx;
font-weight: 700;
color: #111;
margin-bottom: 32rpx;
display: block;
font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Helvetica, Segoe UI, Arial, Roboto, 'PingFang SC', 'miui', 'Hiragino Sans GB', 'Microsoft Yahei', sans-serif;
}
.action-group {
display: flex;
justify-content: space-between;
margin-bottom: 32rpx;
}
.action-item {
display: flex;
flex-direction: column;
align-items: center;
transition: transform 0.2s;
}
.emoji-wrap {
margin-bottom: 12rpx;
}
.emoji {
font-size: 56rpx;
filter: grayscale(100%);
opacity: 0.6;
}
.action-name {
font-size: 24rpx;
color: #999;
}
.action-item.active .emoji {
filter: grayscale(0%);
opacity: 1;
transform: scale(1.2);
}
.action-item.active .action-name {
color: #2953ff;
font-weight: bold;
}
.quick-comment {
display: flex;
align-items: center;
background-color: #f0f2f7;
border-radius: 16rpx;
padding: 8rpx 8rpx 8rpx 32rpx;
}
.comment-input {
flex: 1;
height: 72rpx;
font-size: 28rpx;
}
.input-placeholder {
color: #999;
}
.send-btn {
background-color: #4d44f1;
color: #fff;
font-size: 26rpx;
border-radius: 12rpx;
padding: 0 40rpx;
height: 64rpx;
line-height: 64rpx;
margin: 0;
}
.send-btn::after {
border: none;
}
</style>

View File

@@ -0,0 +1,197 @@
<template>
<view class="comment-item">
<image class="c-avatar" :src="comment.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>
</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>
</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 ActionBadge from "@/components/ActionBadge/ActionBadge.vue";
import { topicItemCommentLike } from "@/api/topicItem";
const props = defineProps({
comment: {
type: Object,
required: true,
default: () => ({})
}
});
const isLiked = ref(props.comment.isLiked || false);
const currentLikes = ref(props.comment.likes || 0);
let isLiking = false; // 防抖标志
watch(() => props.comment.likes, (newVal) => {
currentLikes.value = newVal;
});
watch(() => props.comment.isLiked, (newVal) => {
isLiked.value = newVal || false;
});
const handleLike = async () => {
if (isLiking) return;
isLiking = true;
// 乐观更新 UI
isLiked.value = !isLiked.value;
if (isLiked.value) {
currentLikes.value += 1;
uni.vibrateShort({ type: 'light' });
} else {
currentLikes.value -= 1;
}
try {
// 调用点赞接口 (点赞和取消点赞为同一个接口)
await topicItemCommentLike({ commentId: props.comment.id });
} catch (error) {
// 接口调用失败,回滚 UI 状态
isLiked.value = !isLiked.value;
if (isLiked.value) {
currentLikes.value += 1;
} else {
currentLikes.value -= 1;
}
uni.showToast({ title: '操作失败', icon: 'none' });
} finally {
isLiking = false;
}
};
</script>
<style lang="scss" scoped>
.comment-item {
display: flex;
padding: 32rpx 0;
position: relative;
transition: background-color 0.2s;
}
.comment-item:active {
background-color: #fafbfe;
}
.comment-item::after {
content: '';
position: absolute;
bottom: 0;
left: 104rpx; /* 头像宽度(80) + 间距(24) */
right: 0;
height: 2rpx;
background-color: #f0f2f7;
}
.c-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
margin-right: 24rpx;
background-color: #f5f6fa;
flex-shrink: 0;
border: 2rpx solid #f0f2f7;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.03);
}
.c-content-wrap {
flex: 1;
}
.c-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8rpx;
}
.c-user-info {
display: flex;
align-items: center;
flex-wrap: wrap;
}
.c-name {
font-size: 30rpx;
font-weight: 700; /* 减轻字重,显得更精致 */
color: #1a1a1a;
margin-right: 12rpx;
font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Helvetica, Segoe UI, Arial, Roboto, sans-serif;
}
.c-badge {
display: flex;
align-items: center;
background: linear-gradient(90deg, #f5f6fa, #f0f2f7);
padding: 4rpx 16rpx;
border-radius: 100rpx;
margin-left: 12rpx;
}
.b-icon { font-size: 20rpx; margin-right: 6rpx; }
.b-text { font-size: 20rpx; color: #666; font-weight: bold; }
.c-like {
display: flex;
align-items: center;
color: #999;
font-size: 24rpx;
font-weight: 600;
padding: 8rpx;
margin: -8rpx; /* 增加点击区域 */
}
.like-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='none' stroke='%23999' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3'%3E%3C/path%3E%3C/svg%3E");
background-size: cover;
margin-right: 8rpx;
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.like-icon.is-liked {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23ff4d4f' stroke='%23ff4d4f' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3'%3E%3C/path%3E%3C/svg%3E");
transform: scale(1.15);
}
.liked-text {
color: #ff4d4f;
}
.c-like:active .like-icon {
transform: scale(0.9);
}
.c-time {
font-size: 22rpx;
color: #a1a1a1;
display: block;
margin-bottom: 16rpx;
}
.c-text {
font-size: 30rpx;
color: #2c2c2c;
line-height: 1.65;
word-break: break-all;
}
</style>

View File

@@ -0,0 +1,176 @@
<template>
<view class="rating-card" @tap="handleCardTap">
<view class="card-header">
<image class="item-avatar" :src="FILE_BASE_URL + item.avatarUrl" mode="aspectFill" />
<text class="item-name">{{ item.name }}</text>
<view class="rank-tag" v-if="item.rank <= 3">NO.{{ item.rank }}</view>
<view class="flex-spacer"></view>
<text class="item-score">{{ item.scoreAvg }}</text>
</view>
<view class="quote-box" v-if="item.quote">
<text class="quote-text">{{ item.quote }}</text>
</view>
<view class="action-group">
<view
class="action-item"
v-for="(action, aIndex) in actions"
:key="aIndex"
:class="{ active: item.userAction === action.label }"
@tap.stop="handleAction(action)"
>
<view class="action-icon-wrap" :class="'icon-' + action.value">
<text class="emoji">{{ action.emoji }}</text>
</view>
<text class="action-name">{{ action.label }}</text>
</view>
</view>
</view>
</template>
<script setup>
import { FILE_BASE_URL } from "@/utils/constants";
const actions = [
{ label: '拉', value: 'terrible', emoji: '👎', score: 2, scoreType:1 },
{ label: 'NPC', value: 'bad', emoji: '😐', score: 4, scoreType: 2},
{ label: '人上人', value: 'ok', emoji: '🙂', score: 6, scoreType: 3},
{ label: '顶级', value: 'good', emoji: '🔥', score: 8, scoreType: 4},
{ label: '夯', value: 'god', emoji: '👑', score: 10, scoreType: 5 }
];
const props = defineProps({
item: {
type: Object,
required: true,
default: () => ({})
}
});
const emit = defineEmits(['item-click', 'action-click']);
const handleCardTap = () => {
emit('item-click', props.item.id);
};
const handleAction = async (action) => {
emit('action-click', props.item, action);
};
</script>
<style lang="scss" scoped>
.rating-card {
background-color: #fff;
border-radius: 32rpx;
padding: 32rpx;
margin-bottom: 32rpx;
box-shadow: 0 4rpx 24rpx rgba(0,0,0,0.02);
}
.card-header {
display: flex;
align-items: center;
margin-bottom: 24rpx;
}
.item-avatar {
width: 90rpx;
height: 90rpx;
border-radius: 20rpx;
margin-right: 24rpx;
background-color: #eee;
}
.item-name {
font-size: 36rpx;
font-weight: bold;
color: #111;
margin-right: 16rpx;
}
.rank-tag {
background-color: #eef1ff;
color: #2953ff;
font-size: 22rpx;
font-weight: bold;
padding: 4rpx 16rpx;
border-radius: 10rpx;
}
.flex-spacer {
flex: 1;
}
.item-score {
font-size: 48rpx;
font-weight: 900;
color: #2953ff;
font-family: 'DIN Alternate', sans-serif;
}
.quote-box {
background-color: #f9fafe;
border-radius: 0 20rpx 20rpx 20rpx;
padding: 24rpx 28rpx;
margin-bottom: 36rpx;
border-left: 6rpx solid #c9d4ff;
}
.quote-text {
font-size: 30rpx;
color: #555;
font-style: italic;
line-height: 1.5;
}
.action-group {
display: flex;
justify-content: space-between;
padding: 0 10rpx;
}
.action-item {
display: flex;
flex-direction: column;
align-items: center;
transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.action-icon-wrap {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
background-color: #f5f6fa;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12rpx;
transition: all 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.emoji {
font-size: 44rpx;
filter: grayscale(100%);
opacity: 0.6;
transition: all 0.2s;
}
.action-name {
font-size: 24rpx;
color: #999;
}
.action-item.active .action-icon-wrap {
background-color: #eef1ff;
transform: scale(1.15);
box-shadow: 0 8rpx 16rpx rgba(41, 83, 255, 0.15);
}
.action-item.active .emoji {
filter: grayscale(0%);
opacity: 1;
}
.action-item.active .action-name {
color: #2953ff;
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,186 @@
<template>
<view class="success-popup" v-if="visible" @touchmove.stop.prevent>
<view class="mask" :class="{ 'mask-show': showAnim }" @tap="close"></view>
<view class="popup-content" :class="{ 'content-show': showAnim }">
<view class="icon-wrap">
<text class="emoji">{{ actionData?.emoji || '✨' }}</text>
<!-- 环形扩散特效 -->
<view class="ring ring-1"></view>
<view class="ring ring-2"></view>
<!-- 星星点缀 -->
<view class="sparkle s-1"></view>
<view class="sparkle s-2"></view>
<view class="sparkle s-3"></view>
</view>
<text class="title">{{ type === 'comment' ? '评论成功' : '评分成功' }}</text>
<text class="subtitle" v-if="type === 'comment'">{{ actionData?.label || '你的锐评已发布' }}</text>
<text class="subtitle" v-else>你已将TA评价为{{ actionData?.label || '...' }}</text>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue';
const visible = ref(false);
const showAnim = ref(false);
const actionData = ref(null);
const type = ref('rating');
let timer = null;
const show = (action, popupType = 'rating') => {
actionData.value = action;
type.value = popupType;
visible.value = true;
// 留一点时间让DOM渲染然后添加动画类
setTimeout(() => {
showAnim.value = true;
}, 50);
if (timer) clearTimeout(timer);
// 2秒后自动关闭
timer = setTimeout(() => {
close();
}, 2000);
};
const close = () => {
showAnim.value = false;
setTimeout(() => {
visible.value = false;
}, 300); // 等待淡出动画结束
};
defineExpose({
show,
close
});
</script>
<style lang="scss" scoped>
.success-popup {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
display: flex;
justify-content: center;
align-items: center;
}
.mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
opacity: 0;
transition: opacity 0.3s ease;
}
.mask.mask-show {
opacity: 1;
}
.popup-content {
position: relative;
width: 460rpx;
background-color: #ffffff;
border-radius: 40rpx;
padding: 64rpx 40rpx;
display: flex;
flex-direction: column;
align-items: center;
opacity: 0;
transform: scale(0.8) translateY(20rpx);
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.15);
}
.popup-content.content-show {
opacity: 1;
transform: scale(1) translateY(0);
}
.icon-wrap {
position: relative;
width: 160rpx;
height: 160rpx;
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 40rpx;
}
.emoji {
font-size: 100rpx;
position: relative;
z-index: 10;
animation: emojiBounce 1.2s infinite alternate ease-in-out;
}
@keyframes emojiBounce {
0% { transform: translateY(0) scale(1); }
100% { transform: translateY(-12rpx) scale(1.05); }
}
.ring {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border-radius: 50%;
border: 4rpx solid #4d44f1;
opacity: 0;
}
.ring-1 {
animation: ripple 1.6s cubic-bezier(0.21, 0.53, 0.56, 0.8) infinite;
}
.ring-2 {
animation: ripple 1.6s cubic-bezier(0.21, 0.53, 0.56, 0.8) infinite 0.8s;
}
@keyframes ripple {
0% { width: 100rpx; height: 100rpx; opacity: 0.8; }
100% { width: 260rpx; height: 260rpx; opacity: 0; }
}
.sparkle {
position: absolute;
font-size: 32rpx;
opacity: 0;
z-index: 11;
}
.s-1 { top: -10rpx; right: -20rpx; animation: popStar 1.4s ease-out infinite 0.1s; }
.s-2 { bottom: 10rpx; left: -30rpx; animation: popStar 1.4s ease-out infinite 0.6s; font-size: 24rpx; }
.s-3 { top: 60rpx; left: -40rpx; animation: popStar 1.4s ease-out infinite 1.1s; font-size: 28rpx; }
@keyframes popStar {
0% { transform: scale(0) rotate(0deg); opacity: 0; }
30% { transform: scale(1.2) rotate(45deg); opacity: 1; }
80% { transform: scale(0) rotate(90deg); opacity: 0; }
100% { transform: scale(0) rotate(90deg); opacity: 0; }
}
.title {
font-size: 40rpx;
font-weight: 800;
color: #111;
margin-bottom: 16rpx;
font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Helvetica, Segoe UI, Arial, Roboto, 'PingFang SC', 'miui', 'Hiragino Sans GB', 'Microsoft Yahei', sans-serif;
}
.subtitle {
font-size: 28rpx;
color: #666;
text-align: center;
}
</style>

View File

@@ -0,0 +1,247 @@
<template>
<view class="topic-card">
<!-- 卡片头部标题与统计信息 -->
<view class="topic-header">
<text class="topic-title">{{ topic.title }}</text>
<view class="topic-stats">
<view class="stat-item">
<text class="stat-icon icon-group"></text>
<text>{{ topic.participantCount }}人参与</text>
</view>
<view class="stat-item heat">
<text class="stat-icon icon-fire"></text>
<text>热度 {{ topic.hotScore }} w</text>
</view>
</view>
</view>
<!-- 排行榜列表 -->
<view class="rank-list">
<view
class="rank-item"
v-for="(item, index) in topic.items"
:key="item.id"
@tap="handleItemTap(item.id)"
>
<text 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>
</view>
</view>
<!-- AI 总结 -->
<view class="ai-summary" v-if="topic.aiSummary">
<text class="ai-icon icon-robot"></text>
<view class="summary-text">
<text class="summary-label">AI总结</text>
<text class="summary-content">{{ topic.aiSummary }}</text>
</view>
</view>
<!-- 标签列表 -->
<view class="tag-list">
<view class="tag" v-for="(tag, tIndex) in topic.tags" :key="tIndex">
<text>#{{ tag }}</text>
</view>
</view>
</view>
</template>
<script setup>
import { FILE_BASE_URL } from "@/utils/constants";
const props = defineProps({
topic: {
type: Object,
required: true,
default: () => ({})
}
});
const emit = defineEmits(['item-click']);
const handleItemTap = (itemId) => {
emit('item-click', props.topic.id, itemId);
};
</script>
<style lang="scss" scoped>
.topic-card {
background-color: #ffffff;
border-radius: 40rpx;
padding: 36rpx;
margin-bottom: 32rpx;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.04);
transition: transform 0.2s ease;
}
.topic-card:active {
transform: scale(0.98);
}
.topic-header {
margin-bottom: 32rpx;
}
.topic-title {
font-size: 38rpx;
font-weight: 800;
color: #222;
margin-bottom: 20rpx;
display: block;
letter-spacing: 1rpx;
}
.topic-stats {
display: flex;
align-items: center;
}
.stat-item {
display: flex;
align-items: center;
font-size: 24rpx;
color: #666;
margin-right: 20rpx;
background-color: #f7f7f9;
padding: 6rpx 16rpx;
border-radius: 100rpx;
}
.stat-icon {
width: 24rpx;
height: 24rpx;
margin-right: 8rpx;
display: inline-block;
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");
}
.rank-list {
margin-bottom: 24rpx;
background-color: #fcfcfd;
border-radius: 24rpx;
padding: 16rpx 24rpx;
}
.rank-item {
display: flex;
align-items: center;
padding: 16rpx 0;
border-bottom: 2rpx dashed #eee;
}
.rank-item:last-child {
border-bottom: none;
}
.rank-num {
width: 44rpx;
font-size: 34rpx;
font-weight: 900;
text-align: center;
margin-right: 16rpx;
font-style: italic;
}
.rank-1 {
color: #2953ff;
font-size: 40rpx;
}
.rank-2, .rank-3 {
color: #333;
}
.item-avatar {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
margin-right: 24rpx;
background-color: #f0f0f0;
border: 4rpx solid #fff;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
}
.item-name {
flex: 1;
font-size: 30rpx;
color: #333;
font-weight: 500;
}
.item-score {
font-size: 36rpx;
font-weight: 900;
font-family: Arial, sans-serif;
}
.score-1 {
color: #2953ff;
}
.score-2, .score-3 {
color: #333;
}
.ai-summary {
display: flex;
align-items: flex-start;
padding: 20rpx 24rpx;
background: linear-gradient(135deg, #fbfaff, #f4efff);
border: 2rpx solid #e0d6ff;
border-radius: 20rpx;
margin-bottom: 24rpx;
box-shadow: 0 4rpx 16rpx rgba(123, 70, 241, 0.05);
}
.ai-icon {
width: 32rpx;
height: 32rpx;
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");
}
.summary-text {
flex: 1;
font-size: 26rpx;
line-height: 1.6;
}
.summary-label {
color: #7b46f1;
font-weight: bold;
}
.summary-content {
color: #444;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.tag {
background-color: #f4eaff;
padding: 6rpx 24rpx;
border-radius: 100rpx;
}
.tag text {
color: #9d5bfe;
font-size: 22rpx;
font-weight: 500;
}
</style>

View File

@@ -3,7 +3,7 @@
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "祝福 壁纸 头像",
"navigationBarTitleText": "全民评分",
"enablePullDownRefresh": true,
"navigationStyle": "custom",
"backgroundColor": "#FFFFFF"
@@ -42,9 +42,9 @@
}
},
{
"path": "pages/creation/index",
"path": "pages/release/index",
"style": {
"navigationBarTitleText": "创作中心",
"navigationBarTitleText": "发布中心",
"navigationStyle": "custom",
"backgroundColor": "#fbfbfb"
}
@@ -63,6 +63,22 @@
"enablePullDownRefresh": false,
"navigationStyle": "custom"
}
},
{
"path": "pages/rating/index",
"style": {
"navigationBarTitleText": "全民评分",
"navigationStyle": "custom",
"enablePullDownRefresh": true
}
},
{
"path": "pages/rating/detail",
"style": {
"navigationBarTitleText": "人物详情",
"navigationStyle": "custom",
"enablePullDownRefresh": true
}
}
],
"globalStyle": {
@@ -88,8 +104,8 @@
"selectedIconPath": "static/images/tabBar/home_s.png"
},
{
"text": "创作",
"pagePath": "pages/creation/index",
"text": "发布",
"pagePath": "pages/release/index",
"iconPath": "static/images/tabBar/creation.png",
"selectedIconPath": "static/images/tabBar/creation_s.png"
},

View File

@@ -3,66 +3,46 @@
<!-- 顶部固定栏 (使用 padding-top 避开状态栏) -->
<view class="header-section" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="header-content">
<view class="header-texts">
<text class="title">全民评分</text>
<text class="subtitle">看看全国网友怎么评分</text>
</view>
<image
class="user-avatar"
:src="userInfo.avatarUrl || '/static/images/default-avatar.png'"
mode="aspectFill"
@tap="handleLogin"
/>
<view class="header-texts">
<text class="title">全民评分</text>
<text class="subtitle">看看全国网友怎么评分</text>
</view>
</view>
<!-- 顶部切换 tab -->
<scroll-view class="category-tabs" scroll-x :show-scrollbar="false">
<view class="tabs-wrapper">
<view
class="tab-item"
:class="{ active: currentCategoryId === category.id }"
v-for="category in categories"
:key="category.id"
@tap="switchCategory(category.id)"
>
<text class="tab-text">{{ category.title }}</text>
<view class="tab-line" v-if="currentCategoryId === category.id"></view>
</view>
</view>
</scroll-view>
</view>
<!-- 占位防止固定头部遮挡内容 -->
<view :style="{ height: statusBarHeight + 60 + 'px' }"></view>
<!-- 占位防止固定头部遮挡内容 (增加 tab 的高度) -->
<view :style="{ height: statusBarHeight + 104 + 'px' }"></view>
<!-- 列表内容 -->
<view class="topic-list">
<view class="topic-card" v-for="topic in topicList" :key="topic.id">
<!-- 卡片头部标题与统计信息 -->
<view class="topic-header">
<text class="topic-title">{{ topic.title }}</text>
<view class="topic-stats">
<view class="stat-item">
<text class="stat-icon icon-group"></text>
<text>{{ topic.participants }}人参与</text>
</view>
<view class="stat-item heat">
<text class="stat-icon icon-fire"></text>
<text>热度 {{ topic.heat }}</text>
</view>
</view>
</view>
<!-- 排行榜列表 -->
<view class="rank-list">
<view class="rank-item" v-for="(item, index) in topic.items" :key="item.id">
<text class="rank-num" :class="'rank-' + (index + 1)">{{ index + 1 }}</text>
<image class="item-avatar" :src="item.avatar" mode="aspectFill"></image>
<text class="item-name">{{ item.name }}</text>
<text class="item-score" :class="'score-' + (index + 1)">{{ item.score }}</text>
</view>
</view>
<!-- AI 总结 -->
<view class="ai-summary">
<text class="ai-icon icon-robot"></text>
<view class="summary-text">
<text class="summary-label">AI总结</text>
<text class="summary-content">{{ topic.aiSummary }}</text>
</view>
</view>
<!-- 标签列表 -->
<view class="tag-list">
<view class="tag" v-for="(tag, tIndex) in topic.tags" :key="tIndex">
<text>#{{ tag }}</text>
</view>
</view>
</view>
<TopicCard
v-for="topic in topicList"
:key="topic.id"
:topic="topic"
@item-click="goToRating"
/>
<!-- 加载更多 -->
<view class="load-more">
@@ -88,8 +68,10 @@ import {
} from "@dcloudio/uni-app";
import { useUserStore } from "@/stores/user";
import { getShareReward } from "@/api/system";
import { fetchTopicList, fetchTopicCategories } from "@/api/topic";
import { getShareToken, saveViewRequest } from "@/utils/common.js";
import LoginPopup from "@/components/LoginPopup/LoginPopup.vue";
import TopicCard from "@/components/TopicCard/TopicCard.vue";
import adManager from "@/utils/adManager.js";
const userStore = useUserStore();
@@ -98,6 +80,68 @@ const userInfo = computed(() => userStore?.userInfo || {});
const loading = ref(false);
const hasMore = ref(true);
const currentCategoryId = ref('');
const categories = ref([]);
const currentPage = ref(1);
const loadCategories = async () => {
try {
const res = await fetchTopicCategories();
// 提取真实数据数组
const list = res?.data?.list || res?.list || res || [];
// 增加一个“全部”的默认选项id 置空
categories.value = [{ id: 'recommend', title: '推荐' }, ...list];
// 初始化选中的ID为“全部”
if (!currentCategoryId.value && categories.value.length > 0) {
currentCategoryId.value = categories.value[0].id;
}
// 加载初始列表
// loadTopicList(currentCategoryId.value, currentPage.value);
} catch (error) {
console.error("获取分类失败:", error);
// 兜底数据
categories.value = [
{ id: '', title: '全部' },
{ id: 1, title: '历史' },
{ id: 2, title: '数码' },
];
// 使用兜底也尝试加载一次
loadTopicList(currentCategoryId.value, currentPage.value);
}
};
const loadTopicList = async (categoryId, page = 1) => {
if (page === 1) {
topicList.value = [];
}
try {
loading.value = true;
const res = await fetchTopicList(categoryId, page);
// 提取真实数据数组
const list = res?.list || [];
// 合并数据
topicList.value = [...topicList.value, ...list];
hasMore.value = list.length >= 5;
} catch (error) {
console.error("获取主题列表失败:", error);
hasMore.value = false;
} finally {
loading.value = false;
}
};
const switchCategory = (categoryId) => {
if (currentCategoryId.value === categoryId) return;
currentCategoryId.value = categoryId;
// 切换分类时重新加载数据
currentPage.value = 1;
hasMore.value = true;
loadTopicList(categoryId, currentPage.value);
};
const mockData = [
{
@@ -143,6 +187,12 @@ const mockData = [
const topicList = ref([...mockData]);
const goToRating = (topicId, itemId) => {
uni.navigateTo({
url: `/pages/rating/index?topicId=${topicId}&itemId=${itemId}`
});
};
const handleLogin = () => {
if (!userInfo.value.nickName) {
uni.$emit("show-login-popup");
@@ -155,7 +205,7 @@ const initData = () => {
if (adManager.isAdShowing) return; // 防止广告页返回时立即重新渲染引发 insertTextView:fail
};
onLoad((options) => {
onLoad(async (options) => {
uni.$trackRecord({
eventName: "index_page_visit",
eventType: `visit`,
@@ -163,6 +213,8 @@ onLoad((options) => {
if (options.shareToken) {
saveViewRequest(options.shareToken, "index");
}
await loadCategories(); // 初始化时加载分类
await loadTopicList(currentCategoryId.value); // 初始化时加载主题列表
initData();
});
@@ -178,26 +230,18 @@ const handleLoginSuccess = () => {
};
// 下拉刷新
onPullDownRefresh(() => {
setTimeout(() => {
topicList.value = [...mockData];
hasMore.value = true;
uni.stopPullDownRefresh();
}, 1000);
onPullDownRefresh(async () => {
currentPage.value = 1;
hasMore.value = true;
await loadTopicList(currentCategoryId.value, currentPage.value);
uni.stopPullDownRefresh();
});
// 上拉加载更多
onReachBottom(() => {
if (!hasMore.value || loading.value) return;
loading.value = true;
setTimeout(() => {
const newData = mockData.map(item => ({ ...item, id: item.id + topicList.value.length }));
topicList.value.push(...newData);
loading.value = false;
if (topicList.value.length >= 10) {
hasMore.value = false;
}
}, 1000);
currentPage.value += 1;
loadTopicList(currentCategoryId.value, currentPage.value);
});
onShareAppMessage(async () => {
@@ -240,7 +284,6 @@ onShareTimeline(async () => {
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 32rpx;
height: 120rpx;
@@ -270,165 +313,59 @@ onShareTimeline(async () => {
border-radius: 50%;
border: 2rpx solid #fff;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
margin-right: 24rpx; /* 头像在左边时,增加右侧间距 */
}
/* Tab 样式 */
.category-tabs {
width: 100%;
white-space: nowrap;
height: 88rpx;
}
.tabs-wrapper {
display: inline-flex;
padding: 0 16rpx;
height: 100%;
align-items: center;
}
.tab-item {
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 24rpx;
height: 100%;
position: relative;
}
.tab-text {
font-size: 30rpx;
color: #666;
transition: all 0.3s ease;
}
.tab-item.active .tab-text {
font-size: 34rpx;
font-weight: bold;
color: #111;
}
.tab-line {
position: absolute;
bottom: 12rpx;
width: 32rpx;
height: 6rpx;
background-color: #2953ff;
border-radius: 4rpx;
transition: all 0.3s ease;
}
.topic-list {
padding: 24rpx 32rpx;
}
.topic-card {
background-color: #ffffff;
border-radius: 32rpx;
padding: 32rpx;
margin-bottom: 32rpx;
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.02);
}
.topic-header {
margin-bottom: 32rpx;
}
.topic-title {
font-size: 36rpx;
font-weight: bold;
color: #222;
margin-bottom: 16rpx;
display: block;
}
.topic-stats {
display: flex;
align-items: center;
}
.stat-item {
display: flex;
align-items: center;
font-size: 24rpx;
color: #666;
margin-right: 32rpx;
}
.stat-icon {
width: 24rpx;
height: 24rpx;
margin-right: 8rpx;
display: inline-block;
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");
}
.rank-list {
margin-bottom: 24rpx;
}
.rank-item {
display: flex;
align-items: center;
margin-bottom: 24rpx;
}
.rank-num {
width: 40rpx;
font-size: 32rpx;
font-weight: bold;
text-align: center;
margin-right: 16rpx;
}
.rank-1 {
color: #2953ff;
}
.rank-2, .rank-3 {
color: #333;
}
.item-avatar {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
margin-right: 24rpx;
background-color: #f0f0f0;
}
.item-name {
flex: 1;
font-size: 30rpx;
color: #333;
}
.item-score {
font-size: 34rpx;
font-weight: bold;
}
.score-1 {
color: #2953ff;
}
.score-2, .score-3 {
color: #333;
}
.ai-summary {
display: flex;
align-items: flex-start;
padding: 20rpx 24rpx;
background-color: #ffffff;
border: 2rpx solid #e0d6ff;
border-radius: 20rpx;
margin-bottom: 24rpx;
}
.ai-icon {
width: 32rpx;
height: 32rpx;
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='%234d44f1'%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");
}
.summary-text {
flex: 1;
font-size: 26rpx;
line-height: 1.5;
}
.summary-label {
color: #4d44f1;
font-weight: bold;
}
.summary-content {
color: #555;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.tag {
background-color: #f4eaff;
padding: 6rpx 20rpx;
border-radius: 100rpx;
}
.tag text {
color: #9d5bfe;
font-size: 22rpx;
}
.load-more {
text-align: center;
padding: 30rpx 0;

661
pages/rating/detail.vue Normal file
View File

@@ -0,0 +1,661 @@
<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 class="nav-right">
<text class="search-icon"></text>
<image class="nav-avatar" src="/static/images/default-avatar.png" mode="aspectFill" />
</view>
</view>
</view>
<view :style="{ height: statusBarHeight + 44 + 'px' }"></view>
<view class="content-wrap">
<!-- 头部信息卡片 -->
<view class="hero-card card">
<image class="hero-avatar" :src="detailData.avatar" mode="aspectFill" />
<view class="hero-info">
<view class="hero-name-row">
<text class="hero-name">{{ detailData.name }}</text>
<text class="hero-score">{{ detailData.score }}</text>
</view>
<view class="hero-meta">
<text class="meta-rank">🥇 No.1</text>
<text class="meta-heat">🔥 {{ detailData.hotScore }}w 热度</text>
<text class="meta-count" style="margin-left: 20rpx; color: #999;">{{ detailData.ratingCount }} 人评分</text>
</view>
</view>
</view>
<!-- AI 观察员 -->
<view class="ai-card">
<view class="ai-header">
<text class="ai-icon"></text>
<text class="ai-title">AI观察员</text>
</view>
<text class="ai-content">{{ detailData.aiSummary }}</text>
</view>
<!-- 你的态度 -->
<AttitudePanel
:initial-action="currentAction"
@action-change="handleActionChange"
@send-comment="handleSendComment"
/>
<!-- 大家都在 PK -->
<view class="pk-panel card">
<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>
</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>
</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>
<view class="pk-data-text">
<text class="blue-text">63% 支持</text>
<text class="yellow-text">37% 支持</text>
</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>
</view>
<!-- 评分成功特效弹窗 -->
<SuccessPopup ref="successPopupRef" />
</view>
</template>
<script setup>
import { ref } from 'vue';
import { getStatusBarHeight } from "@/utils/system";
import { onLoad, onPullDownRefresh, onReachBottom } 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 { FILE_BASE_URL } from "@/utils/constants";
import { fetchTopicRatingItems, topicItemScore, topicItemComment, topicItemCommentList } from "@/api/topicItem";
import { formatDate } from "@/utils/date";
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 goBack = () => {
uni.navigateBack();
};
const detailData = ref({
id: '',
name: '',
score: '0.0',
avatar: '',
aiSummary: '加载中...',
userAction: "",
topicId: ''
});
const comments = ref([]);
const itemId = ref('');
const handleActionChange = async (action) => {
const originAction = detailData.value.userAction;
const originScore = detailData.value.score;
const isCancel = detailData.value.userAction === action.label;
try {
detailData.value.userAction = isCancel ? '' : action.label;
currentAction.value = detailData.value.userAction;
const res = await topicItemScore({
topicId: detailData.value.topicId,
itemId: itemId.value,
scoreType: action.scoreType
});
detailData.value.score = res?.scoreAvg || originScore;
// 如果是成功评分(非取消),展示特效弹窗
if (!isCancel && successPopupRef.value) {
successPopupRef.value.show(action);
}
} catch (error) {
detailData.value.userAction = originAction;
detailData.value.score = originScore;
currentAction.value = originAction;
}
};
const handleSendComment = async (payload) => {
if (!detailData.value.topicId || !itemId.value) return;
try {
uni.showLoading({ title: '发送中...' });
await topicItemComment({
topicId: detailData.value.topicId,
itemId: itemId.value,
content: payload.content
});
uni.hideLoading();
// 弹出成功弹窗
if (successPopupRef.value) {
successPopupRef.value.show({
emoji: '💬',
label: '你的锐评已发布'
}, 'comment');
}
// 刷新页面数据以获取最新评论
currentPage.value = 1;
hasMore.value = true;
comments.value = [];
getCommentsData();
} 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;
}
};
const getDetailData = async () => {
if (!itemId.value) return;
try {
uni.showLoading({ title: '加载中' });
const res = await fetchTopicRatingItems(itemId.value);
const data = res?.data || res || {};
detailData.value = {
id: itemId.value,
name: data.name || '未知',
score: data.scoreAvg ? Number(data.scoreAvg).toFixed(1) : '0.0',
avatar: data.avatarUrl ? `${FILE_BASE_URL}${data.avatarUrl}` : '',
aiSummary: data.description || '暂无总结',
hotScore: data.hotScore || '0.00',
topicId: data.topicId || detailData.value.topicId || '',
ratingCount: data.scoreCount || 0,
userAction: data.userAction || ""
};
// 如果接口返回了当前用户的态度,初始化它
if (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();
}
} catch (error) {
console.error('获取详情失败:', error);
uni.showToast({
title: '获取详情失败',
icon: 'none'
});
} finally {
uni.hideLoading();
uni.stopPullDownRefresh();
}
};
onLoad((options) => {
uni.$trackRecord({
eventName: "rating_item_detail_page_visit",
eventType: `visit`,
elementId: options.itemId
})
if (options.topicId) {
detailData.value.topicId = options.topicId;
}
if (options.itemId) {
itemId.value = options.itemId;
getDetailData();
}
});
onPullDownRefresh(() => {
currentPage.value = 1;
hasMore.value = true;
comments.value = [];
getDetailData();
});
onReachBottom(() => {
if (!hasMore.value || loading.value) return;
currentPage.value += 1;
getCommentsData();
});
</script>
<style lang="scss" scoped>
.page-container {
min-height: 100vh;
background-color: #f5f6fa;
padding-bottom: 40rpx;
}
/* 导航栏 */
.nav-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background-color: #f5f6fa;
}
.nav-content {
height: 44px;
display: flex;
justify-content: space-between;
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;
}
.nav-right {
display: flex;
align-items: center;
}
.search-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='%232953ff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E");
background-size: cover;
margin-right: 24rpx;
}
.nav-avatar {
width: 52rpx;
height: 52rpx;
border-radius: 8rpx;
background-color: #eee;
}
.content-wrap {
padding: 24rpx 32rpx;
}
.card {
background-color: #fff;
border-radius: 32rpx;
padding: 32rpx;
margin-bottom: 24rpx;
}
/* 英雄卡片 */
.hero-card {
display: flex;
align-items: center;
}
.hero-avatar {
width: 140rpx;
height: 140rpx;
border-radius: 50%;
margin-right: 32rpx;
border: 4rpx solid #fff;
box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.1);
}
.hero-info {
flex: 1;
}
.hero-name-row {
display: flex;
align-items: baseline;
margin-bottom: 12rpx;
}
.hero-name {
font-size: 44rpx;
font-weight: bold;
color: #111;
margin-right: 16rpx;
}
.hero-score {
font-size: 44rpx;
font-weight: 900;
color: #2953ff;
font-family: 'DIN Alternate', sans-serif;
}
.hero-meta {
display: flex;
align-items: center;
font-size: 24rpx;
color: #666;
}
.meta-rank {
margin-right: 24rpx;
font-weight: bold;
color: #d9a000;
}
.meta-heat {
color: #666;
}
/* AI 观察员 */
.ai-card {
background: #ffffff;
border: 2rpx solid #e0d6ff;
border-radius: 32rpx;
padding: 32rpx;
margin-bottom: 24rpx;
}
.ai-header {
display: flex;
align-items: center;
margin-bottom: 16rpx;
}
.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-title {
font-size: 28rpx;
font-weight: 800;
color: #7b46f1;
}
.ai-content {
font-size: 28rpx;
color: #333;
line-height: 1.6;
}
/* 你的态度面板 */
/* 大家都在 PK */
.section-title {
font-size: 34rpx;
font-weight: 700;
color: #111;
margin-bottom: 32rpx;
display: block;
font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Helvetica, Segoe UI, Arial, Roboto, 'PingFang SC', 'miui', 'Hiragino Sans GB', 'Microsoft Yahei', sans-serif;
}
.pk-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32rpx;
}
.pk-header .section-title {
margin-bottom: 0;
}
.go-compare {
font-size: 24rpx;
color: #2953ff;
display: flex;
align-items: center;
}
.arrow-right {
width: 24rpx;
height: 24rpx;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%232953ff' 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-content {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
padding: 0 40rpx;
}
.pk-person {
display: flex;
flex-direction: column;
align-items: center;
}
.pk-avatar {
width: 110rpx;
height: 110rpx;
border-radius: 50%;
border: 4rpx solid transparent;
margin-bottom: 12rpx;
}
.blue-border { border-color: #2953ff; }
.yellow-border { border-color: #ffc107; }
.pk-name {
font-size: 28rpx;
font-weight: bold;
color: #333;
}
.vs-text {
font-size: 40rpx;
font-weight: 900;
color: #a1a1a1;
}
.pk-bar-wrap {
height: 16rpx;
border-radius: 100rpx;
display: flex;
overflow: hidden;
margin-bottom: 16rpx;
}
.pk-bar { height: 100%; }
.blue-bar { background-color: #4d44f1; }
.yellow-bar { background-color: #ffe066; }
.pk-data-text {
display: flex;
justify-content: space-between;
font-size: 24rpx;
font-weight: bold;
}
.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>

866
pages/rating/index.vue Normal file
View File

@@ -0,0 +1,866 @@
<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>
<view class="nav-logo-wrap">
<image class="nav-logo" src="/static/images/default-avatar.png" mode="aspectFill" />
</view>
<text class="nav-title">全民评分</text>
</view>
</view>
<!-- 顶部占位 -->
<view :style="{ height: statusBarHeight + 44 + 'px' }"></view>
<view class="content-wrap">
<!-- 头部信息 -->
<view class="topic-header">
<text class="topic-title">{{ topicData.title }}</text>
<view class="topic-stats">
<text class="stat-icon fire-icon"></text>
<text class="stat-text">热度 {{ topicData.hotScore }} w</text>
<text class="stat-text margin-left">{{ topicData.participantCount }}人参与</text>
</view>
</view>
<!-- AI 实时观察 -->
<view class="ai-observe">
<view class="ai-header">
<text class="ai-icon"></text>
<text class="ai-title">AI 实时观察</text>
</view>
<text class="ai-content">{{ topicData.aiSummary }}</text>
</view>
<!-- 领奖台 (Top 3) -->
<view class="podium-section" v-if="ratingItems && ratingItems.length >= 3">
<!-- 第二名 -->
<view class="podium-item rank-2" @tap="goToDetail(ratingItems[1].id)">
<view class="avatar-wrap">
<image class="avatar" :src="FILE_BASE_URL + ratingItems[1].avatarUrl" mode="aspectFill" />
<text class="rank-badge badge-2">2</text>
</view>
<view class="info-box box-2">
<text class="name">{{ ratingItems[1].name }}</text>
<text class="score">分数 {{ ratingItems[1].scoreAvg }}</text>
</view>
</view>
<!-- 第一名 -->
<view class="podium-item rank-1" @tap="goToDetail(ratingItems[0].id)">
<view class="avatar-wrap">
<image class="avatar avatar-1" :src="FILE_BASE_URL + ratingItems[0].avatarUrl" mode="aspectFill" />
<text class="rank-badge badge-1">1</text>
</view>
<view class="info-box box-1">
<text class="name name-1">{{ ratingItems[0].name }}</text>
<text class="score score-1">分数 {{ ratingItems[0].scoreAvg }}</text>
</view>
</view>
<!-- 第三名 -->
<view class="podium-item rank-3" @tap="goToDetail(ratingItems[2].id)">
<view class="avatar-wrap">
<image class="avatar" :src="FILE_BASE_URL + ratingItems[2].avatarUrl" mode="aspectFill" />
<text class="rank-badge badge-3">3</text>
</view>
<view class="info-box box-3">
<text class="name">{{ ratingItems[2].name }}</text>
<text class="score">分数 {{ ratingItems[2].scoreAvg }}</text>
</view>
</view>
</view>
<!-- 评分项列表 -->
<view class="rating-list">
<RatingCard
v-for="(item, index) in ratingItems"
:key="item.id"
:item="item"
@item-click="goToDetail"
@action-click="handleAction"
/>
</view>
<!-- 热门锐评 -->
<!-- <view class="comments-section">
<view class="section-header">
<text class="comment-icon"></text>
<text class="section-title">热门锐评</text>
</view>
<view class="comment-input-box">
<text class="edit-icon"></text>
<text class="placeholder">发表你的锐评...</text>
</view>
<view class="comment-list">
<view class="comment-item" v-for="comment in comments" :key="comment.id">
<view class="comment-user">
<image class="comment-avatar" :src="comment.avatar" mode="aspectFill" />
<text class="comment-name">{{ comment.name }}</text>
<text class="comment-time">{{ comment.time }}</text>
</view>
<view class="comment-content">
<text>{{ comment.content }}</text>
</view>
</view>
</view>
</view> -->
<!-- 加载更多 -->
<view class="load-more">
<text>{{ loading ? '加载中...' : (hasMore ? '上拉加载更多' : '没有更多了') }}</text>
</view>
</view>
<!-- 右下角悬浮添加按钮 -->
<view class="fab-btn" @tap="showAddPopup = true">
<text class="plus-icon">+</text>
</view>
<!-- 添加评分项弹窗 -->
<view class="popup-mask" v-if="showAddPopup" @tap="showAddPopup = false">
<view class="popup-content" @tap.stop>
<text class="popup-title">添加评分项</text>
<view class="form-item">
<text class="form-label">上传</text>
<view class="avatar-uploader" @tap="chooseAvatar">
<image v-if="newItem.avatar" :src="newItem.avatar" class="preview-avatar" mode="aspectFill" />
<view v-else class="upload-placeholder">
<text class="camera-icon"></text>
</view>
</view>
</view>
<view class="form-item">
<text class="form-label">名称</text>
<input class="form-input" type="text" v-model="newItem.name" placeholder="请输入名称" placeholder-class="input-placeholder" />
</view>
<view class="popup-actions">
<button class="btn-cancel" @tap="showAddPopup = false">取消</button>
<button class="btn-confirm" @tap="confirmAddItem">确定</button>
</view>
</view>
</view>
<!-- 评分成功特效弹窗 -->
<SuccessPopup ref="successPopupRef" />
</view>
</template>
<script setup>
import { ref } from 'vue';
import { getStatusBarHeight } from "@/utils/system";
import { onPullDownRefresh, onReachBottom, onLoad } 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";
// 状态栏高度处理
const statusBarHeight = ref(getStatusBarHeight() || 44);
const loading = ref(false);
const hasMore = ref(true);
const currentPage = ref(1);
const currentTopicId = ref(null);
const successPopupRef = ref(null);
const goBack = () => {
uni.navigateBack({
delta: 1
});
};
const topicData = ref({
title: '',
heat: '0',
participants: '0',
aiSummary: ''
});
const loadDetail = async (topicId) => {
try {
const res = await fetchTopicDetail(topicId);
const data = res?.data || res || {};
topicData.value = {
title: data.title || '',
heat: data.heat || '0',
participants: data.participants || '0',
aiSummary: data.aiSummary || '暂无总结',
participantCount: data.participantCount || '0',
hotScore: data.hotScore || '0'
};
} catch (e) {
console.error("获取主题详情失败:", e);
}
};
const showAddPopup = ref(false);
const newItem = ref({
name: '',
avatar: ''
});
const chooseAvatar = () => {
uni.chooseImage({
count: 1,
success: (res) => {
newItem.value.avatar = res.tempFilePaths[0];
}
});
};
const confirmAddItem = () => {
if (!newItem.value.name.trim()) {
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' });
};
const ratingItems = ref([]);
const loadItems = async (topicId, page = 1) => {
if (page === 1) {
ratingItems.value = [];
}
try {
loading.value = true;
const res = await fetchTopicRatingItems(topicId, page);
const list = res?.data?.records || res?.records || res?.list || [];
ratingItems.value = [...ratingItems.value, ...list];
hasMore.value = list.length >= 10; // 假设 pageSize 是 10
} catch (error) {
console.error("获取评分项列表失败:", error);
hasMore.value = false;
} finally {
loading.value = false;
}
};
onLoad((options) => {
uni.$trackRecord({
eventName: "rating_topic_detail_page_visit",
eventType: `visit`,
elementId: options.topicId
})
if (options.topicId) {
currentTopicId.value = options.topicId;
loadDetail(options.topicId);
loadItems(options.topicId, 1);
}
});
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) => {
const originAction = item.userAction;
const originScore = item.scoreAvg;
const isCancel = item.userAction === action.label;
try {
item.userAction = isCancel ? '' : action.label;
const res = await topicItemScore({
topicId: currentTopicId.value,
itemId: item.id,
scoreType: action.scoreType
});
item.scoreAvg = res?.scoreAvg || originScore;
// 如果是成功评分(非取消),展示特效弹窗
if (!isCancel && successPopupRef.value) {
successPopupRef.value.show(action);
}
} catch (error) {
item.userAction = originAction;
item.scoreAvg = originScore;
}
};
const goToDetail = (itemId) => {
uni.navigateTo({
url: `/pages/rating/detail?itemId=${itemId}&topicId=${currentTopicId.value}`
});
};
// 下拉刷新
onPullDownRefresh(async () => {
if (currentTopicId.value) {
currentPage.value = 1;
hasMore.value = true;
await Promise.all([
loadDetail(currentTopicId.value),
loadItems(currentTopicId.value, currentPage.value)
]);
}
// 模拟评论刷新(此处未接接口,暂时保留原有模拟刷新逻辑,去掉 mockRatingItems 的重置)
setTimeout(() => {
comments.value = [...mockComments];
uni.stopPullDownRefresh();
}, 1000);
});
// 上拉加载更多
onReachBottom(() => {
if (!hasMore.value || loading.value) return;
if (currentTopicId.value) {
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>
<style lang="scss" scoped>
.page-container {
min-height: 100vh;
background-color: #fafbfe; /* 浅蓝灰色背景 */
padding-bottom: 120rpx; /* 留出底部 tabBar 空间 */
}
/* 导航栏 */
.nav-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background-color: rgba(250, 251, 254, 0.95);
backdrop-filter: blur(20rpx);
}
.nav-content {
height: 44px;
display: flex;
align-items: center;
padding: 0 32rpx;
}
.nav-back {
padding: 10rpx 24rpx 10rpx 0;
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%3Cpolyline points='15 18 9 12 15 6'%3E%3C/polyline%3E%3C/svg%3E");
background-size: cover;
}
.nav-logo-wrap {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: linear-gradient(135deg, #001f3f, #0088a9);
display: flex;
align-items: center;
justify-content: center;
margin-right: 16rpx;
}
.nav-logo {
width: 48rpx;
height: 48rpx;
border-radius: 50%;
}
.nav-title {
font-size: 32rpx;
font-weight: bold;
color: #2953ff;
}
.content-wrap {
padding: 24rpx 32rpx;
}
/* 头部信息 */
.topic-header {
margin-bottom: 32rpx;
margin-top: 16rpx;
}
.topic-title {
font-size: 46rpx;
font-weight: 800;
color: #111;
display: block;
margin-bottom: 20rpx;
letter-spacing: 2rpx;
}
.topic-stats {
display: flex;
align-items: center;
}
.stat-icon.fire-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;
}
.stat-text {
font-size: 24rpx;
color: #FF5A5F;
font-weight: 500;
}
.stat-text.margin-left {
margin-left: 32rpx;
color: #555;
font-weight: normal;
}
/* AI 实时观察 */
.ai-observe {
background: #ffffff;
border: 2rpx solid rgba(77, 68, 241, 0.2);
border-radius: 24rpx;
padding: 28rpx;
margin-bottom: 50rpx;
box-shadow: 0 8rpx 24rpx rgba(77, 68, 241, 0.03);
}
.ai-header {
display: flex;
align-items: center;
margin-bottom: 16rpx;
}
.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-title {
font-size: 28rpx;
font-weight: bold;
color: #4d44f1;
}
.ai-content {
font-size: 26rpx;
color: #444;
line-height: 1.6;
}
/* 领奖台 (Top 3) */
.podium-section {
display: flex;
justify-content: center;
align-items: flex-end;
height: 360rpx;
margin-bottom: 50rpx;
gap: 16rpx;
}
.podium-item {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
.avatar-wrap {
position: relative;
z-index: 2;
margin-bottom: -44rpx; /* 将头像下移,覆盖在底座上 */
}
.avatar {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
border: 4rpx solid #fff;
box-shadow: 0 8rpx 16rpx rgba(0,0,0,0.1);
background-color: #eee;
}
.avatar-1 {
width: 140rpx;
height: 140rpx;
border-color: #ffd700; /* 冠军金边 */
border-width: 6rpx;
}
.rank-badge {
position: absolute;
bottom: 4rpx;
right: -8rpx;
width: 36rpx;
height: 36rpx;
border-radius: 50%;
color: #111;
font-size: 22rpx;
font-weight: 900;
display: flex;
align-items: center;
justify-content: center;
border: 4rpx solid #fff;
}
.badge-1 { background-color: #ffd700; width: 44rpx; height: 44rpx; right: -10rpx; bottom: 0; font-size: 26rpx; }
.badge-2 { background-color: #e2e8f0; }
.badge-3 { background-color: #e2e8f0; }
.info-box {
width: 200rpx;
display: flex;
flex-direction: column;
align-items: center;
padding-top: 56rpx;
padding-bottom: 24rpx;
border-radius: 20rpx;
}
.box-1 {
width: 220rpx;
height: 190rpx;
background: linear-gradient(180deg, #6c5ce7 0%, #5a48eb 100%);
color: #fff;
box-shadow: 0 12rpx 32rpx rgba(108, 92, 231, 0.3);
}
.box-2 {
height: 150rpx;
background-color: #e4e7f3;
}
.box-3 {
height: 150rpx; /* 从 130rpx 调整到 150rpx */
background-color: #eef1f8;
}
.name {
font-size: 30rpx;
font-weight: bold;
color: #333;
margin-bottom: 8rpx;
}
.name-1 {
color: #fff;
font-size: 34rpx;
}
.score {
font-size: 24rpx;
color: #888;
}
.score-1 {
color: rgba(255,255,255,0.8);
}
/* 评分项列表 */
.rating-list {
margin-bottom: 40rpx;
}
/* 热门锐评 */
.comments-section {
margin-bottom: 40rpx;
}
.section-header {
display: flex;
align-items: center;
margin-bottom: 32rpx;
}
.comment-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='%232953ff' 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%3C/svg%3E");
background-size: cover;
margin-right: 12rpx;
}
.section-title {
font-size: 36rpx;
font-weight: bold;
color: #111;
}
.comment-input-box {
background-color: #f0f2f7;
border-radius: 20rpx;
padding: 24rpx 32rpx;
display: flex;
align-items: center;
margin-bottom: 40rpx;
}
.edit-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='%23666'%3E%3Cpath d='M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z'/%3E%3C/svg%3E");
background-size: cover;
margin-right: 16rpx;
}
.placeholder {
font-size: 28rpx;
color: #888;
}
.comment-list {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.comment-item {
background-color: #f5f7fb;
border-radius: 28rpx;
padding: 28rpx;
}
.comment-user {
display: flex;
align-items: center;
margin-bottom: 20rpx;
}
.comment-avatar {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
margin-right: 16rpx;
background-color: #eee;
}
.comment-name {
font-size: 28rpx;
font-weight: bold;
color: #333;
margin-right: 16rpx;
}
.comment-time {
font-size: 24rpx;
color: #999;
}
.comment-content {
font-size: 30rpx;
color: #333;
line-height: 1.6;
}
.load-more {
text-align: center;
padding: 30rpx 0;
color: #999;
font-size: 24rpx;
}
/* 悬浮添加按钮 */
.fab-btn {
position: fixed;
right: 40rpx;
bottom: 160rpx; /* 留出 tabBar 的距离 */
width: 100rpx;
height: 100rpx;
background: linear-gradient(135deg, #4d44f1, #2953ff);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(41, 83, 255, 0.4);
z-index: 99;
transition: transform 0.2s;
}
.fab-btn:active {
transform: scale(0.9);
}
.plus-icon {
color: #fff;
font-size: 60rpx;
font-weight: 300;
line-height: 1;
margin-top: -8rpx;
}
/* 弹窗样式 */
.popup-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
}
.popup-content {
width: 600rpx;
background-color: #fff;
border-radius: 32rpx;
padding: 40rpx;
box-sizing: border-box;
animation: popIn 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
@keyframes popIn {
from { transform: scale(0.8); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.popup-title {
font-size: 36rpx;
font-weight: bold;
color: #111;
text-align: center;
display: block;
margin-bottom: 40rpx;
}
.form-item {
margin-bottom: 32rpx;
}
.form-label {
font-size: 28rpx;
color: #333;
margin-bottom: 16rpx;
display: block;
font-weight: 500;
}
.avatar-uploader {
width: 120rpx;
height: 120rpx;
border-radius: 20rpx;
background-color: #f5f6fa;
border: 2rpx dashed #d1d5db;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.preview-avatar {
width: 100%;
height: 100%;
}
.upload-placeholder {
display: flex;
align-items: center;
justify-content: center;
}
.camera-icon {
width: 48rpx;
height: 48rpx;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23999' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z'%3E%3C/path%3E%3Ccircle cx='12' cy='13' r='4'%3E%3C/circle%3E%3C/svg%3E");
background-size: cover;
}
.form-input {
height: 80rpx;
background-color: #f5f6fa;
border-radius: 16rpx;
padding: 0 24rpx;
font-size: 28rpx;
color: #333;
}
.input-placeholder {
color: #aaa;
}
.popup-actions {
display: flex;
justify-content: space-between;
margin-top: 48rpx;
gap: 24rpx;
}
.btn-cancel, .btn-confirm {
flex: 1;
height: 80rpx;
line-height: 80rpx;
text-align: center;
border-radius: 100rpx;
font-size: 30rpx;
margin: 0;
}
.btn-cancel::after, .btn-confirm::after {
border: none;
}
.btn-cancel {
background-color: #f0f2f7;
color: #666;
}
.btn-confirm {
background: linear-gradient(135deg, #4d44f1, #2953ff);
color: #fff;
font-weight: bold;
}
</style>

759
pages/release/index.vue Normal file
View File

@@ -0,0 +1,759 @@
<template>
<view class="page-container">
<view class="nav-bar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="nav-content">
<view class="nav-side left" @tap="goBack">
<text class="back-icon"></text>
</view>
<text class="nav-title">发起评分</text>
<view class="nav-side right" @tap="handlePreview">
<text class="preview-text">预览</text>
</view>
</view>
</view>
<view :style="{ height: statusBarHeight + 44 + 'px' }"></view>
<view class="content-wrap">
<view class="topic-editor card-block">
<textarea
v-model="formData.title"
class="topic-input"
maxlength="40"
placeholder="输入一个有趣的话题..."
placeholder-class="topic-placeholder"
auto-height
/>
<!-- <textarea
v-model="formData.description"
class="desc-input"
maxlength="120"
placeholder="简单介绍一下这个评分话题..."
placeholder-class="desc-placeholder"
auto-height
/> -->
</view>
<!-- <view class="cover-card" :class="{ 'has-cover': formData.coverUrl }" @tap="chooseCover">
<image
v-if="formData.coverUrl"
:src="FILE_BASE_URL + formData.coverUrl"
class="cover-image"
mode="aspectFill"
/>
<view v-else class="cover-placeholder">
<view class="cover-icon"></view>
<text class="cover-text">上传封面图</text>
</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>
</view>
<view class="section-header">
<text class="section-title">评分对象</text>
<text class="section-count">{{ participants.length }}/20</text>
</view>
<view class="participant-list">
<view
v-for="(item, index) in participants"
:key="item.id"
class="participant-card"
>
<view class="participant-main">
<view class="participant-avatar-wrap" @tap="chooseParticipantAvatar(index)">
<image
v-if="item.avatar"
:src="FILE_BASE_URL + item.avatar"
class="participant-avatar"
mode="aspectFill"
/>
<view v-else class="participant-avatar empty">
<view class="camera-small"></view>
</view>
</view>
<view class="participant-info">
<input
v-model="item.name"
class="participant-name"
maxlength="12"
placeholder="输入评价对象"
placeholder-class="participant-placeholder"
/>
<!-- <input
v-model="item.desc"
class="participant-desc"
maxlength="24"
placeholder="一句话简介"
placeholder-class="participant-placeholder"
/> -->
</view>
</view>
<view class="participant-actions">
<!-- <text class="drag-icon"></text> -->
<text class="delete-icon" @tap="removeParticipant(item.id)">🗑</text>
</view>
</view>
<view class="add-participant" @tap="addParticipant">
<text class="add-plus">+</text>
<text class="add-text">添加评分对象</text>
</view>
</view>
<view class="settings-card card-block">
<view class="setting-row">
<view class="setting-info">
<text class="setting-title">允许用户锐评</text>
<text class="setting-desc">开启后用户可以对评分对象发表评论</text>
</view>
<switch
:checked="formData.allowComment"
color="#4d44f1"
@change="formData.allowComment = $event.detail.value"
/>
</view>
<view class="setting-row">
<view class="setting-info">
<text class="setting-title">允许开启PK</text>
<text class="setting-desc">启用后系统会自动生成对象间的对比PK</text>
</view>
<switch
:checked="formData.allowPk"
color="#4d44f1"
@change="formData.allowPk = $event.detail.value"
/>
</view>
<view class="setting-row last">
<view class="setting-info">
<text class="setting-title">匿名评分</text>
<text class="setting-desc">用户可以隐藏个人身份进行打分</text>
</view>
<switch
:checked="formData.anonymous"
color="#4d44f1"
@change="formData.anonymous = $event.detail.value"
/>
</view>
</view>
</view>
<view class="footer-bar">
<button class="submit-btn" @tap="handleSubmit">发布全民评分</button>
</view>
</view>
</template>
<script setup>
import { reactive, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { getStatusBarHeight } from "@/utils/system";
import { releaseTopic, fetchTopicCategories } from "@/api/topic";
import { smartNavigateBack } from "@/utils/page";
import {
uploadImage,
chooseAndUploadImage,
} from "@/utils/common.js";
import { FILE_BASE_URL } from "@/utils/constants";
const statusBarHeight = ref(getStatusBarHeight() || 0);
const categories = ref([]);
const loadCategories = async () => {
try {
const res = await fetchTopicCategories();
// 假设返回结构为 { data: { list: [{ id: 1, title: '历史' }, ...] } }
// 或者直接是一个数组 [{ id: 1, title: '历史' }, ...]
// 根据实际返回结构调整
const list = res?.data?.list || res?.list || res || [];
categories.value = list;
// 如果存在分类且未选择,则默认选中第一个
if (categories.value.length > 0 && !formData.categoryId) {
formData.categoryId = categories.value[0].id;
}
} catch (error) {
console.error("获取分类失败:", error);
}
};
onLoad(() => {
loadCategories();
});
const formData = reactive({
title: "",
description: "",
coverUrl: "",
categoryId: "",
allowComment: true,
allowPk: true,
anonymous: false,
});
const tagInput = ref("");
const participants = ref([
{
id: 1,
name: "",
desc: "一句话简介",
avatar: "",
},
]);
const goBack = () => {
smartNavigateBack();
};
const handlePreview = () => {
uni.showToast({
title: "预览功能待接入",
icon: "none",
});
};
const chooseCover = async () => {
try {
const urls = await chooseAndUploadImage({ count: 1 });
if (urls && urls.length > 0) {
formData.coverUrl = urls[0];
}
} catch (e) {
console.error("选择封面失败", e);
}
};
const chooseParticipantAvatar = async (index) => {
try {
const urls = await chooseAndUploadImage({ count: 1 });
if (urls && urls.length > 0) {
participants.value[index].avatar = urls[0];
}
} catch (e) {
console.error("选择评分对象头像失败", e);
}
};
const handleAddTag = () => {
const value = tagInput.value.trim();
if (!value) return;
if (formData.tags.includes(value)) {
uni.showToast({
title: "标签已存在",
icon: "none",
});
return;
}
formData.tags.push(value);
tagInput.value = "";
};
const removeTag = (index) => {
formData.tags.splice(index, 1);
};
const addParticipant = () => {
if (participants.value.length >= 20) {
uni.showToast({
title: "最多添加20个对象",
icon: "none",
});
return;
}
participants.value.push({
id: Date.now(),
name: "",
desc: "",
avatar: "",
});
};
const removeParticipant = (id) => {
if (participants.value.length <= 1) {
uni.showToast({
title: "至少保留1个对象",
icon: "none",
});
return;
}
participants.value = participants.value.filter((item) => item.id !== id);
};
const handleSubmit = async () => {
if (!formData.title.trim()) {
uni.showToast({ title: '请输入话题', icon: 'none' });
return;
}
// if (!formData.coverUrl) {
// uni.showToast({ title: '请上传封面图', icon: 'none' });
// return;
// }
if (!formData.categoryId) {
uni.showToast({ title: '请选择话题分类', icon: 'none' });
return;
}
// 检查是否有空的对象名称
const hasEmptyName = participants.value.some(p => !p.name.trim());
if (hasEmptyName) {
uni.showToast({ title: '请完善评分对象名称', icon: 'none' });
return;
}
uni.showLoading({ title: '发布中...' });
try {
const payload = {
...formData,
participants: participants.value,
};
await releaseTopic(payload);
uni.hideLoading();
uni.showToast({
title: "发布成功",
icon: "success",
});
smartNavigateBack({ delay: 1500 });
} catch (error) {
uni.hideLoading();
uni.showToast({
title: "发布失败,请重试",
icon: "none",
});
console.error("发布失败:", error);
}
};
</script>
<style lang="scss" scoped>
.page-container {
min-height: 100vh;
background: #f8f9fc; /* 整体背景调亮 */
padding-bottom: 180rpx;
box-sizing: border-box;
}
.nav-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background: rgba(244, 245, 251, 0.96);
backdrop-filter: blur(18rpx);
}
.nav-content {
height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24rpx;
}
.nav-side {
width: 120rpx;
display: flex;
align-items: center;
}
.nav-side.right {
justify-content: flex-end;
}
.back-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='none' stroke='%23222' 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: 34rpx;
font-weight: 700;
color: #171717;
}
.preview-text {
font-size: 28rpx;
color: #5f63ff;
}
.content-wrap {
padding: 24rpx;
}
/* 统一的卡片风格 */
.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; /* 统一的极淡边框 */
}
.topic-editor {
padding: 32rpx; /* 增加内边距 */
margin-bottom: 24rpx;
}
.topic-input,
.desc-input {
width: 100%;
min-height: 52rpx;
color: #232323;
}
.topic-input {
font-size: 44rpx; /* 稍微减小标题字号,显得更精致 */
font-weight: 800; /* 加粗 */
margin-bottom: 16rpx; /* 增加行距 */
}
.desc-input {
font-size: 28rpx;
color: #666;
line-height: 1.5;
}
.topic-placeholder {
font-size: 44rpx;
font-weight: 800;
color: #c1c2d3;
}
.desc-placeholder {
font-size: 28rpx;
color: #b8b9c9;
}
.cover-card {
height: 340rpx;
border-radius: 36rpx;
background: #e9ecf5;
overflow: hidden;
margin-bottom: 28rpx;
border: 2rpx dashed #d1d5db;
}
.cover-card.has-cover {
border: none; /* 有图片时去掉虚线框 */
}
.cover-image {
width: 100%;
height: 100%;
}
.cover-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #77728b;
}
.cover-icon {
width: 80rpx; /* 图标稍微放大 */
height: 80rpx;
margin-bottom: 24rpx;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2377728b' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='3' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Ccircle cx='8.5' cy='8.5' r='1.5'%3E%3C/circle%3E%3Cpolyline points='21 15 16 10 5 21'%3E%3C/polyline%3E%3C/svg%3E"); /* 替换为更像图片的占位图标 */
background-size: cover;
}
.cover-text {
font-size: 30rpx;
}
.category-group {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
margin-bottom: 24rpx;
}
.category-chip {
min-width: 88rpx;
padding: 14rpx 32rpx; /* 增加内边距 */
border-radius: 999rpx;
background: #e9ecf5; /* 背景调浅一些,更接近设计图 */
color: #5c6176; /* 字体颜色调浅 */
font-size: 28rpx; /* 字体调大 */
font-weight: 500;
text-align: center;
transition: all 0.2s ease;
}
.category-chip.active {
background: linear-gradient(135deg, #6c4af2, #883cf3); /* 更接近设计图的紫色渐变 */
color: #fff;
font-weight: 700;
box-shadow: 0 8rpx 20rpx rgba(108, 74, 242, 0.3); /* 调整阴影颜色 */
}
.tag-box {
padding: 24rpx 32rpx; /* 增加内边距 */
margin-bottom: 32rpx;
}
.selected-tags {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12rpx;
}
.tag-chip {
padding: 8rpx 20rpx;
background-color: #f0f4ff; /* 浅蓝色背景 */
border-radius: 8rpx;
color: #4e46e5;
font-size: 26rpx;
display: flex;
align-items: center;
}
.tag-chip text {
margin-right: 8rpx;
}
.tag-chip::after {
content: "×";
font-size: 28rpx;
color: #8c89e8;
margin-left: 4rpx;
}
.tag-input {
flex: 1;
min-width: 180rpx;
height: 52rpx;
font-size: 26rpx;
color: #555;
}
.tag-input-placeholder {
color: #b9b7c7;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx; /* 增加底部间距 */
padding: 0 8rpx; /* 标题与内容对齐 */
}
.section-title {
font-size: 34rpx; /* 调整大小 */
font-weight: 800; /* 加粗 */
color: #171717;
}
.section-count {
min-width: 78rpx;
height: 46rpx;
line-height: 46rpx;
text-align: center;
border-radius: 14rpx;
background: #e6e9f7;
color: #5c6176;
font-size: 26rpx;
}
.participant-list {
margin-bottom: 32rpx;
}
.participant-card {
display: flex;
justify-content: space-between;
align-items: center;
background: #fff;
border-radius: 36rpx; /* 更圆润的边角 */
padding: 32rpx 28rpx; /* 增加内边距 */
margin-bottom: 24rpx;
border: 2rpx solid #eef0f7; /* 增加细微的描边,更接近设计图质感 */
box-shadow: 0 4rpx 20rpx rgba(72, 82, 130, 0.03); /* 阴影调弱 */
}
.participant-main {
display: flex;
align-items: center;
flex: 1;
}
.participant-avatar-wrap {
margin-right: 20rpx;
}
.participant-avatar {
width: 92rpx;
height: 92rpx;
border-radius: 50%;
background: #e8ebf7;
}
.participant-avatar.empty {
display: flex;
align-items: center;
justify-content: center;
border: 2rpx dashed #b5bad0; /* 加深虚线颜色 */
background: #f8f9fc; /* 空状态背景色 */
}
.camera-small {
width: 38rpx;
height: 38rpx;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238a90a6' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z'%3E%3C/path%3E%3Ccircle cx='12' cy='13' r='4'%3E%3C/circle%3E%3C/svg%3E");
background-size: cover;
}
.participant-info {
flex: 1;
}
.participant-name,
.participant-desc {
width: 100%;
}
.participant-name {
height: 42rpx;
font-size: 30rpx;
font-weight: 700;
color: #202020;
margin-bottom: 10rpx;
}
.participant-desc {
height: 38rpx;
font-size: 24rpx;
color: #676c7f;
}
.participant-placeholder {
color: #b6b9c7;
}
.participant-actions {
width: 56rpx;
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; /* 增加点击区域 */
}
.add-participant {
height: 120rpx; /* 增加高度 */
border: 2rpx dashed #c0c4d6; /* 边框颜色更明显 */
border-radius: 36rpx; /* 圆角一致 */
display: flex;
align-items: center;
justify-content: center;
color: #888d9e; /* 字体颜色微调 */
gap: 16rpx; /* 间距变大 */
background: #f8f9fc; /* 添加底色 */
}
.add-plus {
font-size: 38rpx;
line-height: 1;
}
.add-text {
font-size: 28rpx;
}
.settings-card {
padding: 16rpx 32rpx; /* 增加内边距 */
}
.setting-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx 0; /* 增加行间距 */
border-bottom: 2rpx solid #f0f1f7;
}
.setting-row.last {
border-bottom: none;
}
.setting-info {
flex: 1;
padding-right: 20rpx;
}
.setting-title {
display: block;
font-size: 30rpx; /* 字体稍微调小 */
font-weight: 700; /* 加粗 */
color: #181818;
margin-bottom: 12rpx; /* 增加与描述的间距 */
}
.setting-desc {
display: block;
font-size: 24rpx; /* 描述字体稍微调大 */
color: #8c92a4; /* 颜色调浅 */
line-height: 1.5;
}
.footer-bar {
position: fixed;
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%);
}
.submit-btn {
height: 100rpx;
line-height: 100rpx;
border-radius: 999rpx;
background: linear-gradient(135deg, #5c43f5, #7a2cf3); /* 更接近设计图的深紫色 */
color: #fff;
font-size: 34rpx;
font-weight: 700;
box-shadow: 0 16rpx 32rpx rgba(100, 60, 240, 0.3); /* 加强底部按钮阴影 */
}
.submit-btn::after {
border: none;
}
</style>

View File

@@ -29,8 +29,7 @@ export const uploadImage = (filePath) => {
if (keyJson?.data?.result === "auditFailed") {
reject("图片不符合发布规范,请稍作修改后再试");
} else {
const url = `https://file.lihailezzc.com/${keyJson?.data.key}`;
resolve(url);
resolve(keyJson?.data.key);
}
} catch (e) {
reject(e);
@@ -46,6 +45,54 @@ export const uploadImage = (filePath) => {
});
};
/**
* 统一的选择并上传图片的方法
* @param {Object} options 配置项
* @param {number} options.count 最多可选择的图片数量,默认为 1
* @param {string} options.loadingTitle 上传中提示文案,默认为 '上传中...'
* @returns {Promise<string[]>} 返回一个包含所有上传成功图片 URL 的数组 Promise
*/
export const chooseAndUploadImage = (options = {}) => {
const { count = 1, loadingTitle = '上传中...' } = options;
return new Promise((resolve, reject) => {
uni.chooseImage({
count,
success: async (res) => {
const filePaths = res.tempFilePaths;
if (!filePaths || filePaths.length === 0) {
return resolve([]);
}
uni.showLoading({ title: loadingTitle });
try {
// 并发上传所有选中的图片
const uploadPromises = filePaths.map(path => uploadImage(path));
const urls = await Promise.all(uploadPromises);
uni.hideLoading();
resolve(urls);
} catch (error) {
uni.hideLoading();
uni.showToast({
title: "上传失败",
icon: "none",
});
console.error("图片上传异常:", error);
reject(error);
}
},
fail: (err) => {
// 用户取消选择不作为异常抛出,返回空数组
if (err.errMsg && err.errMsg.indexOf('cancel') !== -1) {
resolve([]);
} else {
reject(err);
}
}
});
});
};
export const saveRecordRequest = async (path, targetId, scene, imageUrl) => {
if (!imageUrl) {
imageUrl = await uploadImage(path);

1
utils/constants.js Normal file
View File

@@ -0,0 +1 @@
export const FILE_BASE_URL = "https://file.lihailezzc.com/";

111
utils/page.js Normal file
View File

@@ -0,0 +1,111 @@
/**
* 页面跳转相关工具方法
*/
/**
* 智能返回方法默认返回上一页如果页面栈只有1层没有上一页则返回到首页
*
* @param {Object} options 配置参数
* @param {number} options.delay 延迟执行的时间,单位毫秒,默认 0
* @param {string} options.homePath 首页的路径,默认为 '/pages/index/index'
*/
export const smartNavigateBack = (options = {}) => {
const { delay = 0, homePath = '/pages/index/index' } = options;
const executeBack = () => {
const pages = getCurrentPages();
if (pages.length > 1) {
// 有上一页,直接返回
uni.navigateBack({
delta: 1
});
} else {
// 页面栈只有一层,返回首页
uni.switchTab({
url: homePath,
fail: () => {
// 如果首页不是 tabBar 页面,则使用 reLaunch
uni.reLaunch({
url: homePath
});
}
});
}
};
if (delay > 0) {
setTimeout(executeBack, delay);
} else {
executeBack();
}
};
/**
* 对象转 URL 参数
* @param {Object} obj 要转换的对象
* @returns {string} 返回 a=1&b=2 格式的字符串
*/
export const objToUrlParams = (obj) => {
if (!obj || typeof obj !== 'object') return '';
const params = [];
for (let key in obj) {
if (obj.hasOwnProperty(key) && obj[key] !== undefined && obj[key] !== null) {
// 过滤掉 undefined 和 null 的参数
params.push(`${encodeURIComponent(key)}=${encodeURIComponent(obj[key])}`);
}
}
return params.join('&');
};
/**
* 封装带参数的跳转方法
* @param {string} url 跳转的页面路径
* @param {Object} params 要传递的参数对象
*/
export const navigateToWithParams = (url, params = {}) => {
const queryStr = objToUrlParams(params);
const finalUrl = queryStr ? `${url}${url.includes('?') ? '&' : '?'}${queryStr}` : url;
uni.navigateTo({
url: finalUrl
});
};
/**
* 获取当前页面的实例
* @returns {Object|null} 当前页面实例
*/
export const getCurrentPage = () => {
const pages = getCurrentPages();
return pages.length > 0 ? pages[pages.length - 1] : null;
};
/**
* 刷新当前页面(重新调用 onLoad 和 onShow
* 注意:部分复杂页面可能需要根据具体业务逻辑单独实现刷新机制
*/
export const refreshCurrentPage = () => {
const currentPage = getCurrentPage();
if (currentPage) {
if (typeof currentPage.onLoad === 'function') {
currentPage.onLoad(currentPage.options || {});
}
if (typeof currentPage.onShow === 'function') {
currentPage.onShow();
}
}
};
/**
* 检查当前是否是指定页面
* @param {string} route 页面路由,例如 'pages/index/index'
* @returns {boolean}
*/
export const isCurrentPage = (route) => {
const currentPage = getCurrentPage();
if (!currentPage) return false;
// 去除可能的首字符 '/'
const checkRoute = route.startsWith('/') ? route.substring(1) : route;
return currentPage.route === checkRoute;
};

View File

@@ -1,7 +1,7 @@
const BASE_URL = "https://api.ai-meng.com";
// const BASE_URL = "https://api.ai-meng.com";
// const BASE_URL = 'http://127.0.0.1:3999'
// const BASE_URL = "http://192.168.1.2:3999";
// const BASE_URL = "http://192.168.31.253:3999";
const BASE_URL = "http://192.168.31.253:3999";
import { useUserStore } from "@/stores/user";
import { getPlatform } from "./system.js";
const platform = getPlatform();
@@ -35,7 +35,7 @@ function hideLoading() {
function getHeaders() {
const userStore = useUserStore();
const headers = {
"x-app-id": "69665538a49b8ae3be50fe5d",
"x-app-id": "6a0d7dbe4c5de50f2ba66475",
"x-platform": platform,
"x-user-id": userStore?.userInfo?.id || "",
};