Files
CosScene/clients/pages/spot/detail.vue
T
2026-05-09 16:40:29 +08:00

999 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { ref, computed } from "vue";
import { onLoad, onNavigationBarButtonTap, onReady } from "@dcloudio/uni-app";
import { getSpotDetail, deleteSpot } from "@/api/spot";
import { resolveImageUrl } from "@/utils/image";
import { checkLogin } from "@/utils/auth";
import { addFavorite, removeFavorite } from "@/api/favorite";
import { rateSpot } from "@/api/rating";
import { useUserStore } from "@/store/user";
import { submitCorrection } from "@/api/correction";
import CommentList from "@/components/comment-list/comment-list.vue";
import RatingStar from "@/components/rating-star/rating-star.vue";
import Skeleton from "@/components/skeleton/skeleton.vue";
const userStore = useUserStore();
const isOwner = computed(() =>
spot.value?.creator?.id && userStore.userInfo?.id === spot.value.creator.id
);
const showCorrection = ref(false);
const showActionMenu = ref(false);
const correctionField = ref("");
const correctionValue = ref("");
const correctionReason = ref("");
const correctionSending = ref(false);
const fieldOptions = [
{ value: "title", label: "地点名称" },
{ value: "city", label: "所在城市" },
{ value: "description", label: "地点介绍" },
{ value: "transport", label: "交通方式" },
{ value: "best_time", label: "最佳拍摄时间" },
{ value: "difficulty", label: "路径难度" },
{ value: "price", label: "收费信息" },
];
const fieldIndex = ref(0);
const onFieldChange = (e) => {
fieldIndex.value = e.detail.value;
correctionField.value = fieldOptions[e.detail.value].value;
};
const openCorrection = () => {
if (!checkLogin()) return;
showActionMenu.value = false;
correctionField.value = fieldOptions[0].value;
fieldIndex.value = 0;
correctionValue.value = "";
correctionReason.value = "";
showCorrection.value = true;
};
const closeCorrection = () => {
showCorrection.value = false;
};
const handleSubmitCorrection = async () => {
if (!correctionValue.value.trim()) {
uni.showToast({ title: "请填写建议内容", icon: "none" });
return;
}
correctionSending.value = true;
try {
await submitCorrection(spotId, {
field_name: correctionField.value,
suggested_value: correctionValue.value.trim(),
reason: correctionReason.value.trim() || null,
});
uni.showToast({ title: "建议已提交", icon: "success" });
closeCorrection();
} catch (e) {
console.error(e);
} finally {
correctionSending.value = false;
}
};
const spot = ref(null);
const isFavorited = ref(false);
const loading = ref(true);
const favLoading = ref(false);
const showRating = ref(false);
const myRating = ref(0);
const ratingLoading = ref(false);
const statusMap = {
pending: { text: "待审核", color: "#f59e0b" },
approved: { text: "已通过", color: "#22c55e" },
rejected: { text: "已拒绝", color: "#ef4444" },
};
const getStatusInfo = (status) =>
statusMap[status] || { text: status, color: "#94a3b8" };
let spotId = null;
onLoad(async (query) => {
if (!query.id) return;
spotId = Number(query.id);
try {
const data = await getSpotDetail(query.id);
spot.value = data;
isFavorited.value = !!data.is_favorited;
} catch (e) {
console.error(e);
uni.showToast({ title: "加载失败", icon: "none" });
} finally {
loading.value = false;
}
});
onNavigationBarButtonTap(() => {
toggleActionMenu();
});
const toggleFavorite = async () => {
if (!checkLogin()) return;
if (!spot.value || favLoading.value) return;
favLoading.value = true;
try {
if (isFavorited.value) {
await removeFavorite(spot.value.id);
isFavorited.value = false;
uni.showToast({ title: "已取消收藏", icon: "none" });
} else {
await addFavorite(spot.value.id);
isFavorited.value = true;
uni.showToast({ title: "已收藏", icon: "none" });
}
} catch (e) {
console.error(e);
} finally {
favLoading.value = false;
}
};
const previewImage = (idx) => {
if (!spot.value?.images?.length) return;
const urls = spot.value.images.map((img) => resolveImageUrl(img.image_url));
uni.previewImage({ urls, current: urls[idx] || urls[0] });
};
const openNavigation = () => {
if (!spot.value) return;
const lat = spot.value.latitude;
const lng = spot.value.longitude;
if (lat == null || lng == null) {
uni.showToast({ title: "暂无坐标信息", icon: "none" });
return;
}
uni.openLocation({
latitude: lat,
longitude: lng,
name: spot.value.title,
address: spot.value.city || "",
});
};
const shareSpot = () => {
if (!spot.value) return;
showActionMenu.value = false;
// #ifdef H5
if (navigator.clipboard) {
const url = window.location.href;
navigator.clipboard.writeText(`${spot.value.title} - ${url}`);
uni.showToast({ title: "链接已复制", icon: "success" });
return;
}
// #endif
// #ifndef H5
uni.share?.({
title: spot.value.title,
summary: spot.value.description || "来次元取景器看看这个取景地吧",
type: 0,
fail: () => {
uni.setClipboardData({
data: `${spot.value.title} - 次元取景器推荐取景地`,
success: () => uni.showToast({ title: "已复制到剪贴板", icon: "success" }),
});
},
});
// #endif
};
const toggleActionMenu = () => {
showActionMenu.value = !showActionMenu.value;
};
const closeActionMenu = () => {
showActionMenu.value = false;
};
const goUser = (userId) => {
if (userId) uni.navigateTo({ url: `/pages/user/index?id=${userId}` });
};
const goEdit = () => {
if (spotId) uni.navigateTo({ url: `/pages/spot/edit?id=${spotId}` });
};
const handleDeleteSpot = () => {
uni.showModal({
title: "确认删除",
content: `确定要删除「${spot.value.title}」吗?此操作不可撤销。`,
confirmColor: "#ef4444",
success: async (res) => {
if (res.confirm) {
try {
await deleteSpot(spotId);
uni.showToast({ title: "已删除", icon: "success" });
setTimeout(() => uni.navigateBack(), 800);
} catch (e) {
console.error(e);
}
}
},
});
};
const submitRating = async (val) => {
if (!checkLogin()) return;
if (ratingLoading.value) return;
myRating.value = val;
ratingLoading.value = true;
try {
await rateSpot(spot.value.id, { score: val });
uni.showToast({ title: "评分成功", icon: "none" });
const data = await getSpotDetail(spot.value.id);
spot.value = data;
showRating.value = false;
} catch (e) {
console.error(e);
} finally {
ratingLoading.value = false;
}
};
</script>
<template>
<view class="detail-page">
<view v-if="showActionMenu" class="menu-mask" @tap="closeActionMenu">
<view class="action-menu" @tap.stop>
<view class="menu-item" @tap="shareSpot">
<uni-icons type="redo" size="16" color="#22c55e" />
<text class="menu-text share-text">分享</text>
</view>
<view v-if="!isOwner" class="menu-item" @tap="openCorrection">
<uni-icons type="compose" size="16" color="#f59e0b" />
<text class="menu-text correct-text">校正</text>
</view>
</view>
</view>
<view v-if="loading" class="loading-state">
<view class="sk-cover shimmer" />
<Skeleton :rows="2" card />
<Skeleton :rows="4" card />
<Skeleton avatar :rows="1" card />
</view>
<view v-else-if="spot" class="content">
<swiper
v-if="spot.images && spot.images.length"
class="image-swiper"
indicator-dots
autoplay
circular
indicator-active-color="#6366f1"
>
<swiper-item v-for="(img, idx) in spot.images" :key="idx" @tap="previewImage(idx)">
<image class="swiper-image" :src="resolveImageUrl(img.image_url)" mode="aspectFill" />
</swiper-item>
</swiper>
<view v-else class="image-placeholder">
<uni-icons type="image" size="48" color="#6366f1" class="placeholder-icon" />
<text class="placeholder-text">暂无图片</text>
</view>
<view class="info-section">
<view class="title-row">
<text class="title">{{ spot.title }}</text>
<view
class="status-badge"
:style="{ background: getStatusInfo(spot.audit_status).color }"
>
<text class="status-text">{{
getStatusInfo(spot.audit_status).text
}}</text>
</view>
</view>
<view class="city-row">
<view class="city-info">
<uni-icons type="location" size="16" color="#6366f1" class="city-icon" />
<text class="city">{{ spot.city }}</text>
</view>
<view class="inline-action nav-inline" @tap="openNavigation">
<uni-icons type="navigate-filled" size="16" color="#3b82f6" />
<text class="inline-action-text nav-text">导航</text>
</view>
</view>
<view v-if="spot.tags && spot.tags.length" class="tags-row">
<view v-for="tag in spot.tags" :key="tag.id" class="detail-tag">
<text class="detail-tag-text">{{ tag.name }}</text>
</view>
</view>
<view class="price-row">
<uni-icons :type="spot.is_free ? 'checkbox-filled' : 'shop'" size="16" :color="spot.is_free ? '#22c55e' : '#f59e0b'" />
<text v-if="spot.is_free" class="price-free">免费</text>
<text v-else class="price-paid">
{{ spot.price_min != null && spot.price_max != null && spot.price_min !== spot.price_max
? `¥${spot.price_min} ~ ¥${spot.price_max}`
: `¥${spot.price_min || spot.price_max || '-'}` }}
</text>
</view>
<view class="rating-row">
<RatingStar :value="Math.round(spot.avg_rating || 0)" :readonly="true" size="32rpx" />
<text class="rating-num">{{ (spot.avg_rating || 0).toFixed(1) }}</text>
<text class="rating-count">{{ spot.rating_count || 0 }}人评分</text>
<view class="rate-action" @tap="showRating = !showRating">
<text class="rate-action-text">{{ showRating ? "取消" : "我要评分" }}</text>
</view>
</view>
<view v-if="showRating" class="my-rating-box">
<text class="my-rating-label">点击星星评分</text>
<RatingStar :value="myRating" size="48rpx" @update:value="submitRating" />
</view>
<view class="action-grid">
<template v-if="isOwner">
<view class="action-chip edit-chip" @tap="goEdit">
<uni-icons type="compose" size="18" color="#6366f1" />
<text class="action-chip-text edit-text">编辑</text>
</view>
<view class="action-chip delete-chip" @tap="handleDeleteSpot">
<uni-icons type="trash" size="18" color="#ef4444" />
<text class="action-chip-text delete-text">删除</text>
</view>
</template>
</view>
</view>
<view v-if="spot.description" class="section">
<text class="section-label">地点介绍</text>
<text class="section-content">{{ spot.description }}</text>
</view>
<view v-if="spot.transport" class="section">
<view class="section-label"><uni-icons type="car" size="16" color="#1e293b" /> 交通方式</view>
<text class="section-content">{{ spot.transport }}</text>
</view>
<view v-if="spot.best_time" class="section">
<view class="section-label"><uni-icons type="calendar" size="16" color="#1e293b" /> 最佳拍摄时间</view>
<text class="section-content">{{ spot.best_time }}</text>
</view>
<view v-if="spot.difficulty" class="section">
<view class="section-label"><uni-icons type="flag" size="16" color="#1e293b" /> 路径难度说明</view>
<text class="section-content">{{ spot.difficulty }}</text>
</view>
<view v-if="spot.creator" class="creator-section">
<view class="creator-card" @tap="goUser(spot.creator.id)">
<image
v-if="spot.creator.avatar_url"
class="creator-avatar"
:src="resolveImageUrl(spot.creator.avatar_url)"
mode="aspectFill"
/>
<view v-else class="creator-avatar creator-avatar-default">
<uni-icons type="person" size="20" color="#6366f1" />
</view>
<view class="creator-info">
<text class="creator-name">{{ spot.creator.nickname || "匿名用户" }}</text>
<view class="creator-meta">
<text class="creator-label">投稿者</text>
<text v-if="spot.creator.city" class="creator-city">{{ spot.creator.city }}</text>
</view>
</view>
<view class="creator-arrow">
<uni-icons type="right" size="16" color="#cbd5e1" />
</view>
</view>
</view>
<CommentList
v-if="spotId"
:spot-id="spotId"
:is-favorited="isFavorited"
@toggle-favorite="toggleFavorite"
/>
<!-- 校正建议弹窗 -->
<view v-if="showCorrection" class="correction-mask" @tap.self="closeCorrection">
<view class="correction-popup">
<text class="correction-title">提交校正建议</text>
<view class="correction-field">
<text class="correction-label">校正字段</text>
<picker :value="fieldIndex" :range="fieldOptions" range-key="label" @change="onFieldChange">
<view class="correction-picker">
<text>{{ fieldOptions[fieldIndex].label }}</text>
<uni-icons type="arrowdown" size="14" color="#94a3b8" />
</view>
</picker>
</view>
<view class="correction-field">
<text class="correction-label">建议内容 *</text>
<textarea
v-model="correctionValue"
class="correction-textarea"
placeholder="请输入正确的信息"
maxlength="500"
/>
</view>
<view class="correction-field">
<text class="correction-label">理由说明</text>
<textarea
v-model="correctionReason"
class="correction-textarea correction-textarea-sm"
placeholder="简要说明校正原因(可选)"
maxlength="200"
/>
</view>
<view class="correction-actions">
<view class="correction-cancel" @tap="closeCorrection">
<text>取消</text>
</view>
<view
class="correction-submit"
:class="{ disabled: correctionSending }"
@tap="handleSubmitCorrection"
>
<text class="correction-submit-text">{{ correctionSending ? "提交中..." : "提交" }}</text>
</view>
</view>
</view>
</view>
</view>
<view v-else class="error-state">
<text>加载失败请返回重试</text>
</view>
</view>
</template>
<style scoped>
.detail-page {
min-height: 100vh;
background: #f5f6fa;
padding-bottom: 280rpx;
}
.loading-state {
padding-top: 0;
}
.sk-cover {
width: 100%;
height: 480rpx;
background: #e2e8f0;
}
.shimmer {
background: linear-gradient(90deg, #e2e8f0 25%, #f1f5f9 50%, #e2e8f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.error-state {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
color: #94a3b8;
font-size: 28rpx;
}
.menu-mask {
position: fixed;
inset: 0;
z-index: 120;
}
.action-menu {
position: absolute;
top: calc(88rpx + env(safe-area-inset-top));
right: 24rpx;
min-width: 180rpx;
background: #ffffff;
border-radius: 16rpx;
box-shadow: 0 10rpx 30rpx rgba(15, 23, 42, 0.16);
overflow: hidden;
}
.menu-item {
display: flex;
align-items: center;
gap: 10rpx;
padding: 22rpx 24rpx;
}
.menu-item + .menu-item {
border-top: 1rpx solid #f1f5f9;
}
.menu-text {
font-size: 26rpx;
color: #1e293b;
font-weight: 500;
}
:global(uni-page-head .detail-nav-menu-btn) {
display: flex;
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 44px;
line-height: 1;
}
:global(uni-page-head .detail-nav-menu-btn .uni-page-head-btn-text) {
width: 0;
opacity: 0;
overflow: hidden;
}
:global(uni-page-head .detail-nav-menu-btn::before) {
content: "";
display: block;
width: 20px;
height: 20px;
background-repeat: no-repeat;
background-position: center;
background-size: 20px 20px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none'%3E%3Ccircle cx='4' cy='10' r='1.8' fill='%231e293b'/%3E%3Ccircle cx='10' cy='10' r='1.8' fill='%231e293b'/%3E%3Ccircle cx='16' cy='10' r='1.8' fill='%231e293b'/%3E%3C/svg%3E");
}
.image-swiper {
width: 100%;
height: 480rpx;
}
.swiper-image {
width: 100%;
height: 100%;
}
.image-placeholder {
width: 100%;
height: 480rpx;
background: linear-gradient(135deg, #e0e7ff, #c7d2fe);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.placeholder-icon {
font-size: 96rpx;
margin-bottom: 12rpx;
}
.placeholder-text {
font-size: 28rpx;
color: #6366f1;
}
.info-section {
background: #ffffff;
padding: 32rpx;
margin-bottom: 16rpx;
}
.title-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
}
.title {
font-size: 38rpx;
font-weight: 700;
color: #1e293b;
flex: 1;
margin-right: 16rpx;
}
.status-badge {
padding: 6rpx 16rpx;
border-radius: 8rpx;
flex-shrink: 0;
}
.status-text {
font-size: 22rpx;
color: #ffffff;
font-weight: 500;
}
.city-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.city-info {
display: flex;
align-items: center;
min-width: 0;
}
.city-icon {
font-size: 28rpx;
margin-right: 8rpx;
}
.city {
font-size: 28rpx;
color: #64748b;
min-width: 0;
}
.inline-action {
display: flex;
align-items: center;
gap: 6rpx;
padding: 10rpx 18rpx;
border-radius: 999rpx;
flex-shrink: 0;
}
.nav-inline {
background: rgba(59, 130, 246, 0.1);
}
.inline-action-text {
font-size: 24rpx;
font-weight: 600;
}
.tags-row {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 16rpx;
}
.detail-tag {
background: rgba(99, 102, 241, 0.1);
padding: 6rpx 20rpx;
border-radius: 20rpx;
}
.detail-tag-text {
font-size: 24rpx;
color: #6366f1;
}
.price-row {
display: flex;
align-items: center;
gap: 8rpx;
margin-top: 16rpx;
}
.price-free {
font-size: 28rpx;
color: #22c55e;
font-weight: 600;
}
.price-paid {
font-size: 28rpx;
color: #f59e0b;
font-weight: 600;
}
.rating-row {
display: flex;
align-items: center;
margin-top: 20rpx;
}
.rating-num {
font-size: 28rpx;
font-weight: 700;
color: #f59e0b;
margin-left: 12rpx;
}
.rating-count {
font-size: 24rpx;
color: #94a3b8;
margin-left: 4rpx;
}
.rate-action {
margin-left: auto;
padding: 8rpx 24rpx;
background: #6366f1;
border-radius: 24rpx;
}
.rate-action-text {
font-size: 24rpx;
color: #ffffff;
font-weight: 500;
}
.my-rating-box {
background: #f5f6fa;
border-radius: 12rpx;
padding: 24rpx;
margin-top: 20rpx;
display: flex;
align-items: center;
}
.my-rating-label {
font-size: 26rpx;
color: #475569;
margin-right: 16rpx;
}
.action-grid {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
margin-top: 24rpx;
}
.action-chip {
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
min-width: 160rpx;
height: 76rpx;
padding: 0 24rpx;
border-radius: 999rpx;
background: #f5f6fa;
}
.action-chip.active {
background: rgba(99, 102, 241, 0.1);
}
.action-chip-text {
font-size: 26rpx;
color: #475569;
font-weight: 500;
}
.action-chip-text.active {
color: #6366f1;
}
.section {
background: #ffffff;
padding: 32rpx;
margin-bottom: 16rpx;
}
.section-label {
font-size: 30rpx;
font-weight: 600;
color: #1e293b;
display: block;
margin-bottom: 16rpx;
}
.section-content {
font-size: 28rpx;
color: #475569;
line-height: 1.7;
display: block;
}
.creator-section {
padding: 0 32rpx;
margin-bottom: 32rpx;
}
.creator-card {
background: #ffffff;
border-radius: 16rpx;
padding: 24rpx;
display: flex;
align-items: center;
}
.creator-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 40rpx;
flex-shrink: 0;
margin-right: 20rpx;
}
.creator-avatar-default {
background: #e0e7ff;
display: flex;
align-items: center;
justify-content: center;
}
.creator-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.creator-name {
font-size: 28rpx;
font-weight: 600;
color: #1e293b;
margin-bottom: 4rpx;
}
.creator-meta {
display: flex;
align-items: center;
gap: 12rpx;
}
.creator-label {
font-size: 22rpx;
color: #94a3b8;
}
.creator-city {
font-size: 22rpx;
color: #6366f1;
}
.creator-arrow {
flex-shrink: 0;
margin-left: 8rpx;
}
.correct-text {
color: #f59e0b;
}
.nav-chip {
background: rgba(59, 130, 246, 0.1);
}
.nav-text {
color: #3b82f6;
}
.share-chip {
background: rgba(34, 197, 94, 0.1);
}
.share-text {
color: #22c55e;
}
.correct-chip {
background: rgba(245, 158, 11, 0.1);
}
.edit-chip {
background: rgba(99, 102, 241, 0.1);
}
.edit-text {
color: #6366f1;
}
.delete-chip {
background: rgba(239, 68, 68, 0.1);
}
.delete-text {
color: #ef4444;
}
.correction-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.correction-popup {
width: 640rpx;
background: #ffffff;
border-radius: 20rpx;
padding: 40rpx;
max-height: 80vh;
overflow-y: auto;
}
.correction-title {
font-size: 32rpx;
font-weight: 700;
color: #1e293b;
display: block;
text-align: center;
margin-bottom: 32rpx;
}
.correction-field {
margin-bottom: 24rpx;
}
.correction-label {
font-size: 26rpx;
color: #64748b;
font-weight: 500;
display: block;
margin-bottom: 12rpx;
}
.correction-picker {
display: flex;
align-items: center;
justify-content: space-between;
height: 80rpx;
background: #f5f6fa;
border-radius: 12rpx;
padding: 0 24rpx;
font-size: 28rpx;
color: #1e293b;
}
.correction-textarea {
width: 100%;
height: 160rpx;
background: #f5f6fa;
border-radius: 12rpx;
padding: 20rpx;
font-size: 28rpx;
color: #334155;
box-sizing: border-box;
}
.correction-textarea-sm {
height: 100rpx;
}
.correction-actions {
display: flex;
margin-top: 32rpx;
gap: 20rpx;
}
.correction-cancel {
flex: 1;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
background: #f5f6fa;
border-radius: 12rpx;
font-size: 28rpx;
color: #64748b;
}
.correction-submit {
flex: 1;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
background: #6366f1;
border-radius: 12rpx;
}
.correction-submit.disabled {
opacity: 0.6;
}
.correction-submit-text {
font-size: 28rpx;
color: #ffffff;
font-weight: 500;
}
</style>