文件
shanghaipeiwan/pwuniapp/pages/community/detail.vue
xcbyy 40e601e345 fix(security): 鉴权签名密钥 fail-closed,禁用仓库内置默认密钥
- ADMIN_TOKEN_SECRET 的随机化闸门由 NODE_ENV 改为 SECURE_MODE:线上以 development 运行时不再使用公开默认密钥(该密钥可伪造管理员/用户 token)

- 运行模式与安全开关常量前移到模块顶部,避免鉴权常量初始化顺序问题

- 启动自检与回归用例同步(109 套件/720 用例全绿)
2026-10-21 12:14:16 +08:00

1261 行
36 KiB
Vue

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

<template>
<view class="post-detail-page" :class="['theme-' + currentThemeKey]" :style="navStyle">
<!-- 顶部沉浸式自定义导航栏 -->
<view class="custom-nav-bar" :style="{ height: (navInfo.totalHeaderHeight || 88) + 'px' }">
<view class="nav-content" :style="{ height: (navInfo.capsuleHeight || 32) + 'px', top: (navInfo.capsuleTop || 48) + 'px' }">
<view class="btn-nav-back btn-active" @click="handleBack">
<image class="nav-back-icon" :src="ICONS.chevronLeft" mode="aspectFit" />
</view>
<view class="nav-title-wrap">
<text class="nav-title">动态详情</text>
</view>
<view class="nav-capsule-placeholder"></view>
</view>
</view>
<!-- 顶部导航栏高度占位(避免内容被固定导航栏遮挡) -->
<view class="nav-bar-placeholder" :style="{ height: (navInfo.totalHeaderHeight || 88) + 'px' }"></view>
<!-- 主体内容区 -->
<view class="detail-scroll-view">
<view class="detail-main-wrap" v-if="post">
<!-- 动态主卡片 -->
<view class="detail-post-card">
<!-- 作者与组局操作行 -->
<view class="detail-author-row">
<image class="author-avatar btn-active" :src="post.avatar" mode="aspectFill" @click.stop="openAuthorProfile(post)" />
<view class="author-meta btn-active" @click.stop="openAuthorProfile(post)">
<view class="name-line">
<text class="author-name">{{ post.author }}</text>
<text class="author-tag" :class="post.tagType">{{ post.tag || getPostCategoryName(post.category) }}</text>
</view>
<view class="sub-line">
<text class="post-time">{{ post.time || '刚刚' }}</text>
<text class="dot-sep">·</text>
<view class="distance-box">
<image class="dist-icon" :src="ICONS.mapPin" mode="aspectFit" />
<text class="dist-text">{{ post.location || '同城商圈' }} ({{ post.distance || '1.0km' }})</text>
</view>
</view>
</view>
<view class="author-actions">
<template v-if="isPostOwner(post)">
<button
class="join-btn btn-active"
style="margin-right: 12rpx;"
@click.stop="handleManageApplies"
>
申请({{ post.applyCount || 0 }})
</button>
<button
class="btn-delete-post btn-active"
@click.stop="handleDeletePost"
>
删除
</button>
</template>
<button
v-else
class="join-btn btn-active"
:class="{ 'btn-applied': post.hasApplied && post.myApplyStatus !== 'REJECTED' }"
:disabled="post.hasApplied && post.myApplyStatus !== 'REJECTED'"
@click.stop="handleJoinParty"
>
{{ post.myApplyStatus === 'ACCEPTED' ? '已同意上车' : (post.myApplyStatus === 'REJECTED' ? '重新申请' : (post.hasApplied ? '待房主确认' : (post.actionText || '加入车队'))) }}
</button>
</view>
</view>
<!-- 动态完整正文 -->
<view class="detail-content-box">
<text class="detail-content-text">{{ post.content }}</text>
</view>
<!-- 话题标签 -->
<view class="detail-topics-row" v-if="post.topics && post.topics.length">
<text v-for="t in post.topics" :key="t" class="detail-topic-pill">#{{ t }}</text>
</view>
<!-- 高清图片展示 -->
<view class="detail-images-grid" v-if="post.images && post.images.length">
<image
v-for="(img, idx) in post.images"
:key="idx"
class="detail-post-img"
:class="{ 'single-img': post.images.length === 1 }"
:src="img"
mode="aspectFill"
@click.stop="previewImage(post.images, idx)"
/>
</view>
<!-- 浏览与点赞统计栏 -->
<view class="detail-stats-strip">
<view class="stats-left">
<text class="stats-item-txt">浏览 {{ post.views || 1 }} 次</text>
<text class="stats-dot">·</text>
<text class="stats-item-txt">{{ post.likes || 0 }} 人觉得很赞</text>
</view>
<view
class="detail-like-pill btn-active"
:class="{ liked: post.liked }"
@click.stop="toggleLike"
>
<image class="like-icon" :src="post.liked ? ICONS.heartFilled : ICONS.heart" mode="aspectFit" />
<text class="like-txt">{{ post.liked ? '已赞' : '点赞' }} {{ post.likes || 0 }}</text>
</view>
</view>
</view>
<!-- 评论互动列表卡 -->
<view class="detail-comments-card">
<view class="comments-section-header">
<text class="section-title">搭子互动留言</text>
<text class="section-count">({{ commentsList.length }})</text>
</view>
<view v-if="loadingComments" class="comments-loading">
<text class="loading-text">正在加载评论...</text>
</view>
<view v-else-if="commentsList.length === 0" class="comments-empty">
<image class="empty-icon" :src="ICONS.messageSquare" mode="aspectFit" />
<text class="empty-text">暂无搭子留言,快来抢占沙发!</text>
</view>
<view v-else class="comments-list">
<view v-for="item in commentsList" :key="item.id" class="comment-item">
<image class="comment-avatar btn-active" :src="item.avatar || 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=100'" mode="aspectFill" @click.stop="openAuthorProfile(item)" />
<view class="comment-body">
<view class="comment-top">
<text class="comment-author" @click.stop="openAuthorProfile(item)">{{ item.author }}</text>
<text class="comment-time">{{ item.createdAt ? '刚刚' : '近期' }}</text>
</view>
<text class="comment-content">{{ item.content }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 底部固定评论与互动栏 -->
<view class="detail-bottom-bar" v-if="post">
<input
class="detail-comment-input"
v-model="commentInput"
placeholder="写下你的留言或约伴诉求..."
placeholder-class="input-placeholder"
maxlength="200"
confirm-type="send"
@confirm="sendComment"
/>
<view
class="bottom-like-btn btn-active"
:class="{ liked: post.liked }"
@click="toggleLike"
>
<image class="like-svg" :src="post.liked ? ICONS.heartFilled : ICONS.heart" mode="aspectFit" />
</view>
<button
class="detail-send-btn btn-active"
:disabled="!commentInput.trim() || sendingComment"
@click="sendComment"
>
发送
</button>
</view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue';
import { onLoad, onShow } from '@dcloudio/uni-app';
import { ICONS } from '@/common/icons.js';
import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js';
import { ensureLoginOrRedirect } from '@/common/role.js';
import { getNavBarInfo } from '@/common/nav.js';
import {
getPosts,
deletePost,
likePost,
getPostComments,
addPostComment,
applyPostParty,
getPostApplies,
handlePostApply,
getUserProfile
} from '@/common/api.js';
const DEFAULT_USER_AVATAR = 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=120';
const currentThemeKey = ref(getCurrentThemeKey());
const navInfo = ref(getNavBarInfo());
const postId = ref(null);
const post = ref(null);
const commentsList = ref([]);
const commentInput = ref('');
const loadingComments = ref(false);
const sendingComment = ref(false);
const currentUserProfile = ref({});
const readCachedUserInfo = () => {
const pwInfo = uni.getStorageSync('PW_USER_INFO') || {};
const uInfo = uni.getStorageSync('userInfo') || {};
const uid = Number(uni.getStorageSync('userId')) || Number(pwInfo.id) || Number(uInfo.id) || 1001;
const rawName = pwInfo.nickname || uInfo.nickname || '';
const nickname = (rawName && !['点击登录 / 注册', '电竞玩家 (我)'].includes(rawName)) ? rawName : '';
const avatar = pwInfo.avatar || uInfo.avatar || '';
return {
id: uid,
nickname,
avatar
};
};
const syncCurrentUserProfile = async () => {
const cached = readCachedUserInfo();
currentUserProfile.value = {
...currentUserProfile.value,
...cached
};
const storedUid = Number(uni.getStorageSync('userId'));
if (!storedUid) return currentUserProfile.value;
try {
const remote = await getUserProfile(storedUid);
if (remote && remote.nickname) {
const merged = {
...cached,
...remote,
nickname: remote.nickname,
avatar: remote.avatar || cached.avatar || DEFAULT_USER_AVATAR
};
currentUserProfile.value = merged;
uni.setStorageSync('PW_USER_INFO', merged);
uni.setStorageSync('userInfo', merged);
}
} catch (_) {}
return currentUserProfile.value;
};
const normalizeCommentItem = (item) => {
if (!item) return item;
const cur = currentUserProfile.value.nickname ? currentUserProfile.value : readCachedUserInfo();
const isMyComment =
(item.userId && Number(item.userId) === Number(cur.id || 1001)) ||
item.author === '电竞玩家 (我)';
return {
...item,
author: (isMyComment && cur.nickname)
? cur.nickname
: ((item.author && item.author !== '电竞玩家 (我)') ? item.author : (cur.nickname || '同城局长')),
avatar: (isMyComment && cur.avatar)
? cur.avatar
: ((item.avatar && !item.avatar.includes('photo-1535713875002-d1d0cf377fde'))
? item.avatar
: (cur.avatar || item.avatar || DEFAULT_USER_AVATAR))
};
};
const navStyle = computed(() => {
const info = navInfo.value || {};
return {
'--safe-header-gap': `${info.totalHeaderHeight || 88}px`,
'--capsule-right-space': `${info.capsuleRightSpace || 0}px`,
'--capsule-height': `${info.capsuleHeight || 32}px`
};
});
const getPostCategoryName = (catKey) => {
const map = {
partner: '同城搭子',
game: '车队开黑',
highlight: '战绩高光'
};
return map[catKey] || '同城动态';
};
const isPostOwner = (target) => {
const cur = currentUserProfile.value.nickname ? currentUserProfile.value : readCachedUserInfo();
return target && (
Number(target.userId) === Number(cur.id || 1001) ||
target.author === '电竞玩家 (我)' ||
(cur.nickname && target.author === cur.nickname) ||
target.isMine === true
);
};
const syncPostLocalState = (rawPost) => {
if (!rawPost) return null;
let localLikedIds = uni.getStorageSync('COMMUNITY_LIKED_POST_IDS') || [];
if (!Array.isArray(localLikedIds)) localLikedIds = [];
let localAppliedIds = uni.getStorageSync('COMMUNITY_APPLIED_POST_IDS') || [];
if (!Array.isArray(localAppliedIds)) localAppliedIds = [];
let viewsMap = uni.getStorageSync('COMMUNITY_POST_VIEWS_MAP') || {};
if (typeof viewsMap !== 'object' || viewsMap === null) viewsMap = {};
const strId = String(rawPost.id);
const serverStatus = rawPost.myApplyStatus || null;
const isApplied = serverStatus ? (serverStatus !== 'REJECTED') : localAppliedIds.includes(strId);
const mergedViews = Math.max(Number(rawPost.views) || 1, Number(viewsMap[strId]) || 0);
return {
...rawPost,
views: mergedViews,
myApplyStatus: serverStatus || (isApplied ? 'PENDING' : null),
liked: localLikedIds.includes(strId) ? true : Boolean(rawPost.liked),
hasApplied: isApplied,
actionText: serverStatus === 'ACCEPTED' ? '已同意上车' : (isApplied ? '待房主确认' : (rawPost.actionText || '加入车队'))
};
};
const loadPostDetailData = async () => {
await syncCurrentUserProfile();
// 优先读取本地缓存的当前点击动态,实现零闪烁秒开
const cached = uni.getStorageSync('ACTIVE_COMMUNITY_POST');
if (cached && (!postId.value || String(cached.id) === String(postId.value))) {
post.value = syncPostLocalState(cached);
}
try {
const list = await getPosts('all');
if (Array.isArray(list) && list.length > 0) {
const matched = postId.value
? list.find(item => String(item.id) === String(postId.value))
: list[0];
if (matched) {
const currentViews = post.value && post.value.views ? Math.max(Number(post.value.views), Number(matched.views || 0)) : (Number(matched.views || 0) + 1);
post.value = syncPostLocalState({
...matched,
views: currentViews
});
uni.setStorageSync('ACTIVE_COMMUNITY_POST', post.value);
}
}
} catch (err) {
console.warn('[PostDetail] 获取远端动态详情降级:', err.message);
}
if (post.value) {
await loadComments(post.value.id);
}
};
const loadComments = async (id) => {
loadingComments.value = true;
try {
const list = await getPostComments(id);
if (list && list.length > 0) {
commentsList.value = list.map(normalizeCommentItem);
} else {
commentsList.value = [
{
id: `cmt_seed_${id}`,
author: (post.value && post.value.author) || '小甜橙',
avatar: (post.value && post.value.avatar) || 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=120',
content: '欢迎各位小伙伴在评论区留言约伴!一起开黑/探店~',
createdAt: '刚刚'
}
];
}
} catch (_) {
commentsList.value = [];
} finally {
loadingComments.value = false;
}
};
const handleBack = () => {
const pages = getCurrentPages();
if (pages.length > 1) {
uni.navigateBack();
} else {
uni.reLaunch({
url: '/pages/community/index'
});
}
};
const openAuthorProfile = (target) => {
if (!target) return;
const authorName = target.author || target.nickname || '同城达人';
let targetId = target.expertId || target.companionId || target.userId;
if (!targetId) {
if (authorName === '小甜橙') targetId = 2005;
else if (authorName.includes('暗影刺客')) targetId = 2004;
else if (authorName === '苏苏学姐') targetId = 2006;
else targetId = target.id || 101;
}
const authorSnapshot = {
id: targetId,
userId: target.userId || targetId,
nickname: authorName,
avatar: target.avatar || DEFAULT_USER_AVATAR,
rankTag: target.tag || '同城认证达人',
levelTag: '实名认证',
game: target.category === 'game' ? '车队开黑' : (target.category === 'highlight' ? '战绩高光' : '同城搭子'),
city: target.location || '北京市 · 朝阳/海淀',
bio: target.content || '热爱同城探店与组局开黑,欢迎一起约伴同行!',
photos: Array.isArray(target.images) && target.images.length > 0 ? target.images : [target.avatar || DEFAULT_USER_AVATAR]
};
uni.setStorageSync('ACTIVE_AUTHOR_PROFILE', authorSnapshot);
const qName = encodeURIComponent(authorSnapshot.nickname);
const qAvatar = encodeURIComponent(authorSnapshot.avatar);
const qTag = encodeURIComponent(authorSnapshot.rankTag);
const qCity = encodeURIComponent(authorSnapshot.city);
uni.navigateTo({
url: `/pages/expert/detail?id=${encodeURIComponent(String(targetId))}&name=${qName}&avatar=${qAvatar}&tag=${qTag}&city=${qCity}`
});
};
const previewImage = (imgs, index) => {
uni.previewImage({
urls: imgs,
current: index
});
};
const toggleLike = async () => {
if (!post.value) return;
if (!ensureLoginOrRedirect(`/pages/community/detail?id=${post.value.id}`)) {
return;
}
const willLike = !post.value.liked;
post.value.liked = willLike;
post.value.likes = Math.max(0, (Number(post.value.likes) || 0) + (willLike ? 1 : -1));
let localLikedIds = uni.getStorageSync('COMMUNITY_LIKED_POST_IDS') || [];
if (!Array.isArray(localLikedIds)) localLikedIds = [];
const strId = String(post.value.id);
if (willLike) {
if (!localLikedIds.includes(strId)) localLikedIds.push(strId);
} else {
localLikedIds = localLikedIds.filter(id => id !== strId);
}
uni.setStorageSync('COMMUNITY_LIKED_POST_IDS', localLikedIds);
uni.setStorageSync('ACTIVE_COMMUNITY_POST', post.value);
uni.showToast({
title: willLike ? '已点赞' : '已取消点赞',
icon: 'none',
duration: 1000
});
try {
const res = await likePost(post.value.id, willLike ? 'like' : 'unlike');
if (res && res.likes !== undefined) {
post.value.likes = res.likes;
}
if (res && res.liked !== undefined) {
post.value.liked = res.liked;
}
uni.setStorageSync('ACTIVE_COMMUNITY_POST', post.value);
} catch (err) {
// 回滚乐观更新,避免"已点赞"却未落盘
console.warn('[PostDetail] 点赞同步异常:', err.message);
post.value.liked = !willLike;
post.value.likes = Math.max(0, (Number(post.value.likes) || 0) + (willLike ? -1 : 1));
let revertIds = uni.getStorageSync('COMMUNITY_LIKED_POST_IDS') || [];
if (!Array.isArray(revertIds)) revertIds = [];
if (willLike) {
revertIds = revertIds.filter(id => id !== strId);
} else if (!revertIds.includes(strId)) {
revertIds.push(strId);
}
uni.setStorageSync('COMMUNITY_LIKED_POST_IDS', revertIds);
uni.setStorageSync('ACTIVE_COMMUNITY_POST', post.value);
uni.showToast({ title: (err && err.message) || '操作失败,请重试', icon: 'none' });
}
};
const sendComment = async () => {
if (!post.value) return;
if (!ensureLoginOrRedirect(`/pages/community/detail?id=${post.value.id}`)) {
return;
}
if (!commentInput.value.trim()) {
uni.showToast({ title: '请输入评论内容', icon: 'none' });
return;
}
sendingComment.value = true;
const content = commentInput.value.trim();
const profile = currentUserProfile.value.nickname
? currentUserProfile.value
: await syncCurrentUserProfile();
const uid = Number(profile.id || uni.getStorageSync('userId')) || 1001;
const authorName = profile.nickname || '同城局长';
const authorAvatar = profile.avatar || DEFAULT_USER_AVATAR;
try {
const res = await addPostComment(post.value.id, {
userId: uid,
author: authorName,
avatar: authorAvatar,
content
});
commentsList.value.push(normalizeCommentItem({
id: res ? res.id : `cmt_${Date.now()}`,
userId: uid,
author: (res && res.author) || authorName,
avatar: (res && res.avatar) || authorAvatar,
content,
createdAt: '刚刚'
}));
post.value.comments = (Number(post.value.comments) || 0) + 1;
uni.setStorageSync('ACTIVE_COMMUNITY_POST', post.value);
commentInput.value = '';
uni.showToast({ title: '评论发表成功', icon: 'success' });
} catch (err) {
uni.showToast({ title: err.message || '发表失败', icon: 'none' });
} finally {
sendingComment.value = false;
}
};
const handleJoinParty = () => {
if (!post.value) return;
if (!ensureLoginOrRedirect(`/pages/community/detail?id=${post.value.id}`)) {
return;
}
if (post.value.hasApplied && post.value.myApplyStatus !== 'REJECTED') {
uni.showToast({
title: post.value.myApplyStatus === 'ACCEPTED' ? '已同意上车' : '请等待房主确认',
icon: 'none'
});
return;
}
uni.showModal({
title: '搭子互动申请',
content: `确定向【${post.value.author}】发起同城约伴 / 上车请求?`,
confirmText: '立即发起',
cancelText: '取消',
confirmColor: '#6366F1',
success: async (res) => {
if (res.confirm) {
uni.showLoading({ title: '正在提交...' });
const profile = currentUserProfile.value.nickname
? currentUserProfile.value
: await syncCurrentUserProfile();
const uid = Number(profile.id || uni.getStorageSync('userId')) || 1001;
const applicantName = profile.nickname || '同城局长';
const applicantAvatar = profile.avatar || DEFAULT_USER_AVATAR;
try {
await applyPostParty(post.value.id, {
applicantId: uid,
applicantName,
applicantAvatar
});
} catch (err) {
uni.hideLoading();
uni.showToast({ title: (err && err.message) || '申请失败,请重试', icon: 'none' });
return;
}
post.value.hasApplied = true;
post.value.myApplyStatus = 'PENDING';
post.value.actionText = '待房主确认';
let localAppliedIds = uni.getStorageSync('COMMUNITY_APPLIED_POST_IDS') || [];
if (!Array.isArray(localAppliedIds)) localAppliedIds = [];
const strId = String(post.value.id);
if (!localAppliedIds.includes(strId)) {
localAppliedIds.push(strId);
uni.setStorageSync('COMMUNITY_APPLIED_POST_IDS', localAppliedIds);
}
uni.setStorageSync('ACTIVE_COMMUNITY_POST', post.value);
uni.hideLoading();
uni.showToast({
title: '申请已送达',
icon: 'success'
});
}
}
});
};
const handleManageApplies = async () => {
if (!post.value) return;
uni.showLoading({ title: '加载申请...' });
try {
const list = await getPostApplies(post.value.id);
uni.hideLoading();
if (!list || list.length === 0) {
uni.showToast({ title: '暂无上车申请', icon: 'none' });
return;
}
const itemList = list.slice(0, 6).map(a => {
const name = a.applicant_name || a.applicantName || '同城搭子';
const st = a.status === 'ACCEPTED' ? '已同意' : (a.status === 'REJECTED' ? '已婉拒' : '待确认');
return `${name} [${st}] - 点击审批`;
});
uni.showActionSheet({
itemList,
success: (res) => {
const target = list[res.tapIndex];
if (!target) return;
const name = target.applicant_name || target.applicantName || '同城搭子';
uni.showModal({
title: '审批搭子申请',
content: `申请人:${name}\n请选择是否同意该搭子加入车队/同城局:`,
confirmText: '同意上车',
cancelText: '婉拒申请',
confirmColor: '#10B981',
success: async (mRes) => {
const newStatus = mRes.confirm ? 'ACCEPTED' : (mRes.cancel ? 'REJECTED' : null);
if (!newStatus) return;
try {
await handlePostApply(target.id, newStatus);
uni.showToast({
title: newStatus === 'ACCEPTED' ? '已同意上车' : '已婉拒申请',
icon: 'success'
});
loadPostDetailData();
} catch (err) {
uni.showToast({ title: err.message || '操作失败', icon: 'none' });
}
}
});
}
});
} catch (err) {
uni.hideLoading();
uni.showToast({ title: '加载失败', icon: 'none' });
}
};
const handleDeletePost = () => {
if (!post.value) return;
uni.showModal({
title: '删除动态确认',
content: '确定要删除这条动态吗?删除后将无法恢复。',
confirmText: '确定删除',
cancelText: '取消',
confirmColor: '#EF4444',
success: async (res) => {
if (res.confirm) {
try {
uni.showLoading({ title: '正在删除...' });
await deletePost(post.value.id);
uni.hideLoading();
uni.showToast({ title: '动态已删除', icon: 'success' });
setTimeout(() => {
handleBack();
}, 500);
} catch (err) {
uni.hideLoading();
uni.showToast({ title: (err && err.message) || '删除失败,请重试', icon: 'none' });
}
}
}
});
};
onLoad((options) => {
if (options && options.id) {
postId.value = options.id;
}
loadPostDetailData();
});
onShow(() => {
navInfo.value = getNavBarInfo();
currentThemeKey.value = getCurrentThemeKey();
applyThemeToNativeUI(currentThemeKey.value);
});
</script>
<style lang="scss" scoped>
.post-detail-page {
min-height: 100vh;
background-color: #0A0F1D;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.custom-nav-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: var(--safe-header-gap);
background: rgba(10, 15, 29, 0.92);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
z-index: 1000;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
.nav-content {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: var(--capsule-height);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24rpx;
.btn-nav-back {
width: 64rpx;
height: 64rpx;
border-radius: 32rpx;
background: rgba(255, 255, 255, 0.08);
display: flex;
align-items: center;
justify-content: center;
.nav-back-icon {
width: 36rpx;
height: 36rpx;
}
}
.nav-title-wrap {
position: absolute;
left: 50%;
transform: translateX(-50%);
text-align: center;
pointer-events: none;
.nav-title {
font-size: 32rpx;
font-weight: 700;
color: #F8FAFC;
letter-spacing: 1rpx;
}
}
.nav-capsule-placeholder {
width: var(--capsule-right-space);
}
}
}
.nav-bar-placeholder {
height: var(--safe-header-gap);
width: 100%;
flex-shrink: 0;
}
.detail-scroll-view {
flex: 1;
box-sizing: border-box;
}
.detail-main-wrap {
padding: 24rpx 28rpx calc(160rpx + env(safe-area-inset-bottom));
display: flex;
flex-direction: column;
gap: 24rpx;
}
.detail-post-card {
background: #151D30;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 28rpx;
padding: 32rpx 28rpx;
box-shadow: 0 8rpx 28rpx rgba(0, 0, 0, 0.25);
.detail-author-row {
display: flex;
align-items: center;
margin-bottom: 28rpx;
.author-avatar {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
margin-right: 20rpx;
border: 2rpx solid rgba(255, 255, 255, 0.14);
flex-shrink: 0;
}
.author-meta {
flex: 1;
min-width: 0;
.name-line {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 8rpx;
.author-name {
font-size: 30rpx;
font-weight: 700;
color: #F8FAFC;
}
.author-tag {
font-size: 20rpx;
padding: 2rpx 12rpx;
border-radius: 8rpx;
font-weight: 600;
color: #F59E0B;
background: rgba(245, 158, 11, 0.15);
&.cool-tag {
color: #818CF8;
background: rgba(99, 102, 241, 0.15);
}
&.gold-tag {
color: #FBBF24;
background: rgba(251, 191, 36, 0.15);
}
}
}
.sub-line {
display: flex;
align-items: center;
font-size: 22rpx;
color: #94A3B8;
.dot-sep {
margin: 0 8rpx;
}
.distance-box {
display: flex;
align-items: center;
color: #38BDF8;
.dist-icon {
width: 22rpx;
height: 22rpx;
margin-right: 4rpx;
}
}
}
}
.author-actions {
display: flex;
align-items: center;
margin-left: 16rpx;
flex-shrink: 0;
.join-btn {
height: 60rpx;
line-height: 58rpx;
padding: 0 28rpx;
border-radius: 30rpx;
font-size: 24rpx;
font-weight: 600;
color: #818CF8;
background: rgba(99, 102, 241, 0.15);
border: 1px solid rgba(99, 102, 241, 0.35);
margin: 0;
&.btn-applied {
color: #94A3B8;
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.12);
}
}
.btn-delete-post {
height: 60rpx;
line-height: 58rpx;
padding: 0 22rpx;
border-radius: 30rpx;
font-size: 22rpx;
color: #EF4444;
background: rgba(239, 68, 68, 0.12);
border: 1px solid rgba(239, 68, 68, 0.3);
margin: 0;
}
}
}
.detail-content-box {
margin-bottom: 24rpx;
.detail-content-text {
font-size: 30rpx;
line-height: 1.75;
color: #F1F5F9;
word-break: break-all;
}
}
.detail-topics-row {
display: flex;
flex-wrap: wrap;
gap: 14rpx;
margin-bottom: 28rpx;
.detail-topic-pill {
font-size: 24rpx;
font-weight: 500;
color: #818CF8;
background: rgba(99, 102, 241, 0.12);
padding: 6rpx 18rpx;
border-radius: 12rpx;
}
}
.detail-images-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16rpx;
margin-bottom: 28rpx;
.detail-post-img {
width: 100%;
height: 300rpx;
border-radius: 20rpx;
background: rgba(255, 255, 255, 0.04);
&.single-img {
grid-column: span 2;
height: 420rpx;
}
}
}
.detail-stats-strip {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 24rpx;
border-top: 1px solid rgba(255, 255, 255, 0.06);
.stats-left {
display: flex;
align-items: center;
font-size: 24rpx;
color: #94A3B8;
.stats-dot {
margin: 0 12rpx;
}
}
.detail-like-pill {
display: flex;
align-items: center;
gap: 10rpx;
padding: 10rpx 26rpx;
border-radius: 28rpx;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
.like-icon {
width: 30rpx;
height: 30rpx;
}
.like-txt {
font-size: 24rpx;
color: #CBD5E1;
font-weight: 600;
}
&.liked {
background: rgba(239, 68, 68, 0.15);
border-color: rgba(239, 68, 68, 0.35);
.like-txt {
color: #EF4444;
}
}
}
}
}
.detail-comments-card {
background: #151D30;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 28rpx;
padding: 32rpx 28rpx;
box-shadow: 0 8rpx 28rpx rgba(0, 0, 0, 0.25);
.comments-section-header {
display: flex;
align-items: baseline;
gap: 10rpx;
margin-bottom: 28rpx;
.section-title {
font-size: 30rpx;
font-weight: 700;
color: #F8FAFC;
}
.section-count {
font-size: 24rpx;
color: #94A3B8;
}
}
.comments-loading,
.comments-empty {
padding: 60rpx 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
.empty-icon {
width: 72rpx;
height: 72rpx;
opacity: 0.45;
}
.loading-text,
.empty-text {
font-size: 26rpx;
color: #94A3B8;
}
}
.comments-list {
display: flex;
flex-direction: column;
gap: 28rpx;
.comment-item {
display: flex;
gap: 20rpx;
.comment-avatar {
width: 68rpx;
height: 68rpx;
border-radius: 50%;
flex-shrink: 0;
}
.comment-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 8rpx;
padding-bottom: 24rpx;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
.comment-top {
display: flex;
justify-content: space-between;
align-items: center;
.comment-author {
font-size: 26rpx;
font-weight: 600;
color: #E2E8F0;
}
.comment-time {
font-size: 22rpx;
color: #64748B;
}
}
.comment-content {
font-size: 26rpx;
color: #CBD5E1;
line-height: 1.55;
word-break: break-all;
}
}
&:last-child .comment-body {
border-bottom: none;
padding-bottom: 0;
}
}
}
}
.detail-bottom-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 20rpx 28rpx calc(24rpx + env(safe-area-inset-bottom));
background: rgba(14, 22, 40, 0.96);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-top: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
align-items: center;
gap: 18rpx;
z-index: 990;
.detail-comment-input {
flex: 1;
height: 76rpx;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 38rpx;
padding: 0 28rpx;
font-size: 26rpx;
color: #F8FAFC;
}
.bottom-like-btn {
width: 76rpx;
height: 76rpx;
border-radius: 38rpx;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
.like-svg {
width: 38rpx;
height: 38rpx;
}
&.liked {
background: rgba(239, 68, 68, 0.15);
border-color: rgba(239, 68, 68, 0.35);
}
}
.detail-send-btn {
width: 132rpx;
height: 76rpx;
line-height: 76rpx;
font-size: 26rpx;
font-weight: 600;
border-radius: 38rpx;
background: linear-gradient(135deg, #6366F1 0%, #4F46E5 100%);
color: #FFFFFF;
text-align: center;
padding: 0;
margin: 0;
box-shadow: 0 4rpx 14rpx rgba(99, 102, 241, 0.35);
&:disabled {
opacity: 0.5;
}
}
}
// 暖阳晨曦模式适配 (theme-warm)
.post-detail-page.theme-warm {
background-color: #F8F6F0;
.custom-nav-bar {
background: rgba(248, 246, 240, 0.95);
border-bottom-color: #E7E5E4;
.nav-content {
.btn-nav-back {
background: #FFFFFF;
border: 1px solid #E7E5E4;
}
.nav-title-wrap .nav-title {
color: #1C1917;
}
}
}
.detail-post-card {
background: #FFFFFF;
border-color: #E7E5E4;
box-shadow: 0 4rpx 20rpx rgba(28, 25, 23, 0.04);
.detail-author-row {
.author-meta .name-line .author-name {
color: #1C1917;
}
.author-actions .join-btn {
background: rgba(234, 88, 12, 0.1);
color: #EA580C;
border-color: rgba(234, 88, 12, 0.3);
&.btn-applied {
background: #F1F5F9;
color: #94A3B8;
border-color: #CBD5E1;
}
}
}
.detail-content-box .detail-content-text {
color: #292524;
}
.detail-topics-row .detail-topic-pill {
color: #EA580C;
background: rgba(234, 88, 12, 0.08);
}
.detail-stats-strip {
border-top-color: #F5F2EC;
.detail-like-pill {
background: #F5F2EC;
border-color: #E7E5E4;
.like-txt {
color: #57534E;
}
&.liked {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.3);
.like-txt {
color: #EF4444;
}
}
}
}
}
.detail-comments-card {
background: #FFFFFF;
border-color: #E7E5E4;
box-shadow: 0 4rpx 20rpx rgba(28, 25, 23, 0.04);
.comments-section-header .section-title {
color: #1C1917;
}
.comments-list .comment-item .comment-body {
border-bottom-color: #F5F2EC;
.comment-top .comment-author {
color: #1C1917;
}
.comment-content {
color: #44403C;
}
}
}
.detail-bottom-bar {
background: rgba(253, 251, 247, 0.96);
border-top-color: #E7E5E4;
.detail-comment-input {
background: #F5F2EC;
border-color: #E7E5E4;
color: #1C1917;
}
.bottom-like-btn {
background: #F5F2EC;
border-color: #E7E5E4;
&.liked {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.3);
}
}
.detail-send-btn {
background: linear-gradient(135deg, #F97316 0%, #EA580C 100%);
box-shadow: 0 4rpx 14rpx rgba(234, 88, 12, 0.35);
}
}
}
</style>