Initial project commit
This commit is contained in:
@@ -0,0 +1,651 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import { createSpot, uploadImage } from "@/api/spot";
|
||||
import { getTags } from "@/api/tag";
|
||||
import { addTagToSpot } from "@/api/tag";
|
||||
import { extractList } from "@/utils/request";
|
||||
import { checkLogin } from "@/utils/auth";
|
||||
|
||||
onShow(() => { checkLogin(); });
|
||||
|
||||
const form = ref({
|
||||
title: "",
|
||||
city: "",
|
||||
description: "",
|
||||
transport: "",
|
||||
best_time: "",
|
||||
difficulty: "",
|
||||
is_free: true,
|
||||
price_type: "fixed",
|
||||
price_min: "",
|
||||
price_max: "",
|
||||
longitude: "",
|
||||
latitude: "",
|
||||
});
|
||||
|
||||
const images = ref([]);
|
||||
const submitting = ref(false);
|
||||
const uploading = ref(false);
|
||||
const uploadProgress = ref("");
|
||||
const locationName = ref("");
|
||||
|
||||
const allTags = ref([]);
|
||||
const selectedTagIds = ref([]);
|
||||
|
||||
const fetchTags = async () => {
|
||||
try {
|
||||
const res = await getTags();
|
||||
allTags.value = extractList(res);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
fetchTags();
|
||||
|
||||
const toggleTag = (tagId) => {
|
||||
const idx = selectedTagIds.value.indexOf(tagId);
|
||||
if (idx >= 0) {
|
||||
selectedTagIds.value.splice(idx, 1);
|
||||
} else {
|
||||
if (selectedTagIds.value.length >= 5) {
|
||||
uni.showToast({ title: "最多选择5个标签", icon: "none" });
|
||||
return;
|
||||
}
|
||||
selectedTagIds.value.push(tagId);
|
||||
}
|
||||
};
|
||||
|
||||
const onLocationPicked = (data) => {
|
||||
form.value.longitude = data.longitude;
|
||||
form.value.latitude = data.latitude;
|
||||
form.value.city = data.city || "";
|
||||
locationName.value = data.name || "已选择位置";
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
uni.$on("locationPicked", onLocationPicked);
|
||||
});
|
||||
onUnmounted(() => {
|
||||
uni.$off("locationPicked", onLocationPicked);
|
||||
});
|
||||
|
||||
const chooseLocation = () => {
|
||||
uni.navigateTo({ url: "/pages/spot/pick-location" });
|
||||
};
|
||||
|
||||
const chooseImages = () => {
|
||||
const remaining = 9 - images.value.length;
|
||||
if (remaining <= 0) {
|
||||
uni.showToast({ title: "最多添加9张图片", icon: "none" });
|
||||
return;
|
||||
}
|
||||
uni.chooseImage({
|
||||
count: remaining,
|
||||
sizeType: ["compressed"],
|
||||
sourceType: ["album", "camera"],
|
||||
success: (res) => {
|
||||
images.value.push(...res.tempFilePaths);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const removeImage = (idx) => {
|
||||
images.value.splice(idx, 1);
|
||||
};
|
||||
|
||||
const validate = () => {
|
||||
if (!form.value.title.trim()) {
|
||||
uni.showToast({ title: "请输入地点名称", icon: "none" });
|
||||
return false;
|
||||
}
|
||||
if (!form.value.city) {
|
||||
uni.showToast({ title: "请先通过定位获取所在城市", icon: "none" });
|
||||
return false;
|
||||
}
|
||||
if (form.value.longitude === "" || form.value.longitude == null ||
|
||||
form.value.latitude === "" || form.value.latitude == null) {
|
||||
uni.showToast({ title: "请选择地图坐标位置", icon: "none" });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!validate() || submitting.value) return;
|
||||
submitting.value = true;
|
||||
|
||||
try {
|
||||
const data = {
|
||||
title: form.value.title.trim(),
|
||||
city: form.value.city.trim(),
|
||||
longitude: Number(form.value.longitude),
|
||||
latitude: Number(form.value.latitude),
|
||||
description: form.value.description.trim(),
|
||||
transport: form.value.transport.trim(),
|
||||
best_time: form.value.best_time.trim(),
|
||||
difficulty: form.value.difficulty.trim(),
|
||||
is_free: form.value.is_free,
|
||||
price_min: form.value.is_free ? null : (parseFloat(form.value.price_min) || null),
|
||||
price_max: form.value.is_free
|
||||
? null
|
||||
: form.value.price_type === "range"
|
||||
? (parseFloat(form.value.price_max) || null)
|
||||
: (parseFloat(form.value.price_min) || null),
|
||||
tag_ids: selectedTagIds.value,
|
||||
};
|
||||
|
||||
if (images.value.length > 0) {
|
||||
uploading.value = true;
|
||||
uploadProgress.value = "正在上传图片…";
|
||||
const uploadedUrls = [];
|
||||
for (let i = 0; i < images.value.length; i++) {
|
||||
uploadProgress.value = `正在上传图片 ${i + 1}/${images.value.length}...`;
|
||||
const result = await uploadImage(images.value[i]);
|
||||
uploadedUrls.push(result.url);
|
||||
}
|
||||
data.image_urls = uploadedUrls;
|
||||
uploading.value = false;
|
||||
uploadProgress.value = "";
|
||||
}
|
||||
|
||||
await createSpot(data);
|
||||
uni.showToast({ title: "提交成功,待审核", icon: "success" });
|
||||
setTimeout(() => {
|
||||
uni.switchTab({ url: "/pages/index/index" });
|
||||
}, 1000);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="create-page">
|
||||
<view class="form-card">
|
||||
<view class="field">
|
||||
<text class="label">地点名称 *</text>
|
||||
<input
|
||||
v-model="form.title"
|
||||
class="input"
|
||||
placeholder="请输入取景地名称"
|
||||
placeholder-class="placeholder"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="field">
|
||||
<text class="label">地点介绍</text>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
class="textarea"
|
||||
placeholder="描述这个取景地的特色..."
|
||||
placeholder-class="placeholder"
|
||||
maxlength="500"
|
||||
:auto-height="false"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="field">
|
||||
<text class="label">交通方式</text>
|
||||
<textarea
|
||||
v-model="form.transport"
|
||||
class="textarea textarea-sm"
|
||||
placeholder="如何到达这个地点?"
|
||||
placeholder-class="placeholder"
|
||||
maxlength="300"
|
||||
:auto-height="false"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="field">
|
||||
<text class="label">最佳拍摄时间</text>
|
||||
<input
|
||||
v-model="form.best_time"
|
||||
class="input"
|
||||
placeholder="例如:下午4-6点,黄金时段"
|
||||
placeholder-class="placeholder"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="field">
|
||||
<text class="label">路径难度说明</text>
|
||||
<textarea
|
||||
v-model="form.difficulty"
|
||||
class="textarea textarea-sm"
|
||||
placeholder="路况如何?是否适合携带大量器材?"
|
||||
placeholder-class="placeholder"
|
||||
maxlength="300"
|
||||
:auto-height="false"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="field">
|
||||
<text class="label">是否收费</text>
|
||||
<view class="free-toggle">
|
||||
<view
|
||||
class="toggle-btn"
|
||||
:class="{ active: form.is_free }"
|
||||
@tap="form.is_free = true; form.price_min = ''; form.price_max = ''"
|
||||
>
|
||||
<text class="toggle-text">免费</text>
|
||||
</view>
|
||||
<view
|
||||
class="toggle-btn"
|
||||
:class="{ active: !form.is_free }"
|
||||
@tap="form.is_free = false"
|
||||
>
|
||||
<text class="toggle-text">收费</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="field" v-if="!form.is_free">
|
||||
<text class="label">价格类型</text>
|
||||
<view class="free-toggle">
|
||||
<view
|
||||
class="toggle-btn"
|
||||
:class="{ active: form.price_type === 'fixed' }"
|
||||
@tap="form.price_type = 'fixed'; form.price_max = ''"
|
||||
>
|
||||
<text class="toggle-text">固定价格</text>
|
||||
</view>
|
||||
<view
|
||||
class="toggle-btn"
|
||||
:class="{ active: form.price_type === 'range' }"
|
||||
@tap="form.price_type = 'range'"
|
||||
>
|
||||
<text class="toggle-text">区间价格</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="field" v-if="!form.is_free && form.price_type === 'fixed'">
|
||||
<text class="label">价格(元)</text>
|
||||
<input
|
||||
v-model="form.price_min"
|
||||
class="input"
|
||||
type="digit"
|
||||
placeholder="请输入价格,例如:50"
|
||||
placeholder-class="placeholder"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="field" v-if="!form.is_free && form.price_type === 'range'">
|
||||
<text class="label">价格区间(元)</text>
|
||||
<view class="price-range">
|
||||
<input
|
||||
v-model="form.price_min"
|
||||
class="input price-input"
|
||||
type="digit"
|
||||
placeholder="最低价"
|
||||
placeholder-class="placeholder"
|
||||
/>
|
||||
<text class="price-sep">~</text>
|
||||
<input
|
||||
v-model="form.price_max"
|
||||
class="input price-input"
|
||||
type="digit"
|
||||
placeholder="最高价"
|
||||
placeholder-class="placeholder"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="field" v-if="allTags.length > 0">
|
||||
<text class="label">标签(最多5个)</text>
|
||||
<view class="tag-selector">
|
||||
<view
|
||||
v-for="tag in allTags"
|
||||
:key="tag.id"
|
||||
class="tag-chip"
|
||||
:class="{ active: selectedTagIds.includes(tag.id) }"
|
||||
@tap="toggleTag(tag.id)"
|
||||
>
|
||||
<text class="tag-chip-text">{{ tag.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="field">
|
||||
<text class="label">坐标位置</text>
|
||||
<view class="location-picker" @tap="chooseLocation">
|
||||
<uni-icons type="location" size="18" color="#6366f1" />
|
||||
<text v-if="locationName" class="location-text">{{ locationName }}</text>
|
||||
<text v-else class="location-placeholder">点击选择地图位置</text>
|
||||
<uni-icons type="right" size="16" color="#cbd5e1" />
|
||||
</view>
|
||||
<view v-if="form.longitude && form.latitude" class="coord-display">
|
||||
<text class="coord-text">经度: {{ form.longitude }} / 纬度: {{ form.latitude }}</text>
|
||||
</view>
|
||||
<view class="auto-city-display">
|
||||
<text class="auto-city-label">所在城市</text>
|
||||
<text v-if="form.city" class="auto-city-text">{{ form.city }}</text>
|
||||
<text v-else class="auto-city-placeholder">选择地图位置后自动识别</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-card">
|
||||
<view class="field">
|
||||
<text class="label">添加图片(最多9张)</text>
|
||||
<view class="image-grid">
|
||||
<view
|
||||
v-for="(img, idx) in images"
|
||||
:key="idx"
|
||||
class="image-item"
|
||||
>
|
||||
<image class="preview-img" :src="img" mode="aspectFill" />
|
||||
<view class="remove-btn" @tap="removeImage(idx)">
|
||||
<text class="remove-icon">✕</text>
|
||||
</view>
|
||||
</view>
|
||||
<view
|
||||
v-if="images.length < 9"
|
||||
class="image-item add-btn"
|
||||
@tap="chooseImages"
|
||||
>
|
||||
<text class="add-icon">+</text>
|
||||
<text class="add-text">添加</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="submit-area">
|
||||
<button
|
||||
class="submit-btn"
|
||||
:loading="submitting"
|
||||
:disabled="uploading || submitting"
|
||||
@tap="handleSubmit"
|
||||
>
|
||||
{{ uploading ? uploadProgress : submitting ? "提交中..." : "提交审核" }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.create-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f6fa;
|
||||
padding: 24rpx 32rpx;
|
||||
padding-bottom: 48rpx;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
background: #ffffff;
|
||||
border-radius: 16rpx;
|
||||
padding: 32rpx;
|
||||
margin-bottom: 24rpx;
|
||||
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.field:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
display: block;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
height: 84rpx;
|
||||
background: #f5f6fa;
|
||||
border-radius: 12rpx;
|
||||
padding: 0 24rpx;
|
||||
font-size: 28rpx;
|
||||
color: #1e293b;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
height: 200rpx;
|
||||
background: #f5f6fa;
|
||||
border-radius: 12rpx;
|
||||
padding: 20rpx 24rpx;
|
||||
font-size: 28rpx;
|
||||
color: #1e293b;
|
||||
box-sizing: border-box;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.textarea-sm {
|
||||
height: 140rpx;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.location-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 84rpx;
|
||||
background: #f5f6fa;
|
||||
border-radius: 12rpx;
|
||||
padding: 0 24rpx;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.location-text {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
color: #1e293b;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.location-placeholder {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.coord-display {
|
||||
margin-top: 12rpx;
|
||||
padding: 12rpx 24rpx;
|
||||
background: rgba(99, 102, 241, 0.06);
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.coord-text {
|
||||
font-size: 24rpx;
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.auto-city-display {
|
||||
margin-top: 12rpx;
|
||||
padding: 16rpx 24rpx;
|
||||
background: #f8fafc;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.auto-city-label {
|
||||
display: block;
|
||||
font-size: 22rpx;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 6rpx;
|
||||
}
|
||||
|
||||
.auto-city-text {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
color: #1e293b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.auto-city-placeholder {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.image-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.image-item {
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
border-radius: 12rpx;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.preview-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
position: absolute;
|
||||
top: 8rpx;
|
||||
right: 8rpx;
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.remove-icon {
|
||||
color: #ffffff;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
background: #f5f6fa;
|
||||
border: 2rpx dashed #cbd5e1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.add-icon {
|
||||
font-size: 48rpx;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.add-text {
|
||||
font-size: 22rpx;
|
||||
color: #94a3b8;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.free-toggle {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
flex: 1;
|
||||
height: 80rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f6fa;
|
||||
border-radius: 12rpx;
|
||||
border: 2rpx solid #e2e8f0;
|
||||
}
|
||||
|
||||
.toggle-btn.active {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.toggle-text {
|
||||
font-size: 28rpx;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.toggle-btn.active .toggle-text {
|
||||
color: #6366f1;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.price-range {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.price-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.price-sep {
|
||||
font-size: 28rpx;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.tag-selector {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
padding: 10rpx 24rpx;
|
||||
background: #f5f6fa;
|
||||
border-radius: 32rpx;
|
||||
border: 2rpx solid #e2e8f0;
|
||||
}
|
||||
|
||||
.tag-chip.active {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.tag-chip-text {
|
||||
font-size: 26rpx;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.tag-chip.active .tag-chip-text {
|
||||
color: #6366f1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.submit-area {
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
background: #6366f1;
|
||||
color: #ffffff;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
border-radius: 16rpx;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.submit-btn[disabled] {
|
||||
background: #a5b4fc;
|
||||
}
|
||||
|
||||
.submit-btn::after {
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,998 @@
|
||||
<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>
|
||||
@@ -0,0 +1,520 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { getSpotDetail, updateSpot } from "@/api/spot";
|
||||
import { getTags } from "@/api/tag";
|
||||
import { extractList } from "@/utils/request";
|
||||
import { checkLogin } from "@/utils/auth";
|
||||
|
||||
const form = ref({
|
||||
title: "",
|
||||
city: "",
|
||||
description: "",
|
||||
transport: "",
|
||||
best_time: "",
|
||||
difficulty: "",
|
||||
is_free: true,
|
||||
price_type: "fixed",
|
||||
price_min: "",
|
||||
price_max: "",
|
||||
longitude: "",
|
||||
latitude: "",
|
||||
});
|
||||
|
||||
const submitting = ref(false);
|
||||
const loading = ref(true);
|
||||
const locationName = ref("");
|
||||
const allTags = ref([]);
|
||||
const selectedTagIds = ref([]);
|
||||
let spotId = null;
|
||||
|
||||
const fetchTags = async () => {
|
||||
try {
|
||||
const res = await getTags();
|
||||
allTags.value = extractList(res);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTag = (tagId) => {
|
||||
const idx = selectedTagIds.value.indexOf(tagId);
|
||||
if (idx >= 0) {
|
||||
selectedTagIds.value.splice(idx, 1);
|
||||
} else {
|
||||
if (selectedTagIds.value.length >= 5) {
|
||||
uni.showToast({ title: "最多选择5个标签", icon: "none" });
|
||||
return;
|
||||
}
|
||||
selectedTagIds.value.push(tagId);
|
||||
}
|
||||
};
|
||||
|
||||
const onLocationPicked = (data) => {
|
||||
form.value.longitude = data.longitude;
|
||||
form.value.latitude = data.latitude;
|
||||
form.value.city = data.city || "";
|
||||
locationName.value = data.name || "已选择位置";
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
uni.$on("locationPicked", onLocationPicked);
|
||||
});
|
||||
onUnmounted(() => {
|
||||
uni.$off("locationPicked", onLocationPicked);
|
||||
});
|
||||
|
||||
const chooseLocation = () => {
|
||||
uni.navigateTo({ url: "/pages/spot/pick-location" });
|
||||
};
|
||||
|
||||
onLoad(async (query) => {
|
||||
if (!checkLogin()) return;
|
||||
if (!query.id) return;
|
||||
spotId = Number(query.id);
|
||||
await fetchTags();
|
||||
|
||||
try {
|
||||
const data = await getSpotDetail(spotId);
|
||||
form.value.title = data.title || "";
|
||||
form.value.city = data.city || "";
|
||||
form.value.description = data.description || "";
|
||||
form.value.transport = data.transport || "";
|
||||
form.value.best_time = data.best_time || "";
|
||||
form.value.difficulty = data.difficulty || "";
|
||||
form.value.is_free = data.is_free !== false;
|
||||
form.value.longitude = data.longitude || "";
|
||||
form.value.latitude = data.latitude || "";
|
||||
|
||||
if (!form.value.is_free) {
|
||||
const pMin = data.price_min;
|
||||
const pMax = data.price_max;
|
||||
form.value.price_min = pMin != null ? String(pMin) : "";
|
||||
form.value.price_max = pMax != null ? String(pMax) : "";
|
||||
form.value.price_type = (pMin != null && pMax != null && pMin !== pMax) ? "range" : "fixed";
|
||||
}
|
||||
|
||||
if (data.longitude && data.latitude) {
|
||||
locationName.value = data.city || "已选择位置";
|
||||
}
|
||||
if (data.tags && data.tags.length) {
|
||||
selectedTagIds.value = data.tags.map((t) => t.id);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
uni.showToast({ title: "加载失败", icon: "none" });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const validate = () => {
|
||||
if (!form.value.title.trim()) {
|
||||
uni.showToast({ title: "请输入地点名称", icon: "none" });
|
||||
return false;
|
||||
}
|
||||
if (!form.value.city) {
|
||||
uni.showToast({ title: "请先通过定位获取所在城市", icon: "none" });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!validate() || submitting.value) return;
|
||||
submitting.value = true;
|
||||
try {
|
||||
const data = {
|
||||
title: form.value.title.trim(),
|
||||
city: form.value.city.trim(),
|
||||
description: form.value.description.trim() || null,
|
||||
transport: form.value.transport.trim() || null,
|
||||
best_time: form.value.best_time.trim() || null,
|
||||
difficulty: form.value.difficulty.trim() || null,
|
||||
is_free: form.value.is_free,
|
||||
price_min: form.value.is_free ? null : (parseFloat(form.value.price_min) || null),
|
||||
price_max: form.value.is_free
|
||||
? null
|
||||
: form.value.price_type === "range"
|
||||
? (parseFloat(form.value.price_max) || null)
|
||||
: (parseFloat(form.value.price_min) || null),
|
||||
};
|
||||
if (form.value.longitude && form.value.latitude) {
|
||||
data.longitude = Number(form.value.longitude);
|
||||
data.latitude = Number(form.value.latitude);
|
||||
}
|
||||
|
||||
data.tag_ids = selectedTagIds.value;
|
||||
await updateSpot(spotId, data);
|
||||
uni.showToast({ title: "保存成功", icon: "success" });
|
||||
setTimeout(() => uni.navigateBack(), 800);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="edit-page">
|
||||
<view v-if="loading" class="loading-state">
|
||||
<text>加载中...</text>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<view class="form-card">
|
||||
<view class="field">
|
||||
<text class="label">地点名称 *</text>
|
||||
<input
|
||||
v-model="form.title"
|
||||
class="input"
|
||||
placeholder="请输入取景地名称"
|
||||
placeholder-class="placeholder"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="field">
|
||||
<text class="label">地点介绍</text>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
class="textarea"
|
||||
placeholder="描述这个取景地的特色..."
|
||||
placeholder-class="placeholder"
|
||||
maxlength="500"
|
||||
:auto-height="false"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="field">
|
||||
<text class="label">交通方式</text>
|
||||
<textarea
|
||||
v-model="form.transport"
|
||||
class="textarea textarea-sm"
|
||||
placeholder="如何到达这个地点?"
|
||||
placeholder-class="placeholder"
|
||||
maxlength="300"
|
||||
:auto-height="false"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="field">
|
||||
<text class="label">最佳拍摄时间</text>
|
||||
<input
|
||||
v-model="form.best_time"
|
||||
class="input"
|
||||
placeholder="例如:下午4-6点,黄金时段"
|
||||
placeholder-class="placeholder"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="field">
|
||||
<text class="label">路径难度说明</text>
|
||||
<textarea
|
||||
v-model="form.difficulty"
|
||||
class="textarea textarea-sm"
|
||||
placeholder="路况如何?是否适合携带大量器材?"
|
||||
placeholder-class="placeholder"
|
||||
maxlength="300"
|
||||
:auto-height="false"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="field">
|
||||
<text class="label">是否收费</text>
|
||||
<view class="free-toggle">
|
||||
<view
|
||||
class="toggle-btn"
|
||||
:class="{ active: form.is_free }"
|
||||
@tap="form.is_free = true; form.price_min = ''; form.price_max = ''"
|
||||
>
|
||||
<text class="toggle-text">免费</text>
|
||||
</view>
|
||||
<view
|
||||
class="toggle-btn"
|
||||
:class="{ active: !form.is_free }"
|
||||
@tap="form.is_free = false"
|
||||
>
|
||||
<text class="toggle-text">收费</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="field" v-if="!form.is_free">
|
||||
<text class="label">价格类型</text>
|
||||
<view class="free-toggle">
|
||||
<view
|
||||
class="toggle-btn"
|
||||
:class="{ active: form.price_type === 'fixed' }"
|
||||
@tap="form.price_type = 'fixed'; form.price_max = ''"
|
||||
>
|
||||
<text class="toggle-text">固定价格</text>
|
||||
</view>
|
||||
<view
|
||||
class="toggle-btn"
|
||||
:class="{ active: form.price_type === 'range' }"
|
||||
@tap="form.price_type = 'range'"
|
||||
>
|
||||
<text class="toggle-text">区间价格</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="field" v-if="!form.is_free && form.price_type === 'fixed'">
|
||||
<text class="label">价格(元)</text>
|
||||
<input
|
||||
v-model="form.price_min"
|
||||
class="input"
|
||||
type="digit"
|
||||
placeholder="请输入价格,例如:50"
|
||||
placeholder-class="placeholder"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="field" v-if="!form.is_free && form.price_type === 'range'">
|
||||
<text class="label">价格区间(元)</text>
|
||||
<view class="price-range">
|
||||
<input
|
||||
v-model="form.price_min"
|
||||
class="input price-input"
|
||||
type="digit"
|
||||
placeholder="最低价"
|
||||
placeholder-class="placeholder"
|
||||
/>
|
||||
<text class="price-sep">~</text>
|
||||
<input
|
||||
v-model="form.price_max"
|
||||
class="input price-input"
|
||||
type="digit"
|
||||
placeholder="最高价"
|
||||
placeholder-class="placeholder"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="field" v-if="allTags.length > 0">
|
||||
<text class="label">标签(最多5个)</text>
|
||||
<view class="tag-selector">
|
||||
<view
|
||||
v-for="tag in allTags"
|
||||
:key="tag.id"
|
||||
class="tag-chip"
|
||||
:class="{ active: selectedTagIds.includes(tag.id) }"
|
||||
@tap="toggleTag(tag.id)"
|
||||
>
|
||||
<text class="tag-chip-text">{{ tag.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="field">
|
||||
<text class="label">坐标位置</text>
|
||||
<view class="location-picker" @tap="chooseLocation">
|
||||
<uni-icons type="location" size="18" color="#6366f1" />
|
||||
<text v-if="locationName" class="location-text">{{ locationName }}</text>
|
||||
<text v-else class="location-placeholder">点击选择地图位置</text>
|
||||
<uni-icons type="right" size="16" color="#cbd5e1" />
|
||||
</view>
|
||||
<view v-if="form.longitude && form.latitude" class="coord-display">
|
||||
<text class="coord-text">经度: {{ form.longitude }} / 纬度: {{ form.latitude }}</text>
|
||||
</view>
|
||||
<view class="auto-city-display">
|
||||
<text class="auto-city-label">所在城市</text>
|
||||
<text v-if="form.city" class="auto-city-text">{{ form.city }}</text>
|
||||
<text v-else class="auto-city-placeholder">选择地图位置后自动识别</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="submit-area">
|
||||
<button
|
||||
class="submit-btn"
|
||||
:loading="submitting"
|
||||
:disabled="submitting"
|
||||
@tap="handleSubmit"
|
||||
>
|
||||
{{ submitting ? "保存中..." : "保存修改" }}
|
||||
</button>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.edit-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f6fa;
|
||||
padding: 24rpx 32rpx;
|
||||
padding-bottom: 48rpx;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
color: #94a3b8;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
background: #ffffff;
|
||||
border-radius: 16rpx;
|
||||
padding: 32rpx;
|
||||
margin-bottom: 24rpx;
|
||||
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.field { margin-bottom: 32rpx; }
|
||||
.field:last-child { margin-bottom: 0; }
|
||||
|
||||
.label {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
display: block;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
height: 84rpx;
|
||||
background: #f5f6fa;
|
||||
border-radius: 12rpx;
|
||||
padding: 0 24rpx;
|
||||
font-size: 28rpx;
|
||||
color: #1e293b;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
height: 200rpx;
|
||||
background: #f5f6fa;
|
||||
border-radius: 12rpx;
|
||||
padding: 20rpx 24rpx;
|
||||
font-size: 28rpx;
|
||||
color: #1e293b;
|
||||
box-sizing: border-box;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.textarea-sm { height: 140rpx; }
|
||||
.placeholder { color: #94a3b8; }
|
||||
|
||||
.location-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 84rpx;
|
||||
background: #f5f6fa;
|
||||
border-radius: 12rpx;
|
||||
padding: 0 24rpx;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.location-text {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
color: #1e293b;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.location-placeholder { flex: 1; font-size: 28rpx; color: #94a3b8; }
|
||||
|
||||
.coord-display {
|
||||
margin-top: 12rpx;
|
||||
padding: 12rpx 24rpx;
|
||||
background: rgba(99, 102, 241, 0.06);
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.coord-text { font-size: 24rpx; color: #6366f1; }
|
||||
|
||||
.auto-city-display {
|
||||
margin-top: 12rpx;
|
||||
padding: 16rpx 24rpx;
|
||||
background: #f8fafc;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.auto-city-label {
|
||||
display: block;
|
||||
font-size: 22rpx;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 6rpx;
|
||||
}
|
||||
|
||||
.auto-city-text {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
color: #1e293b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.auto-city-placeholder {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.free-toggle { display: flex; gap: 16rpx; }
|
||||
|
||||
.toggle-btn {
|
||||
flex: 1;
|
||||
height: 80rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f6fa;
|
||||
border-radius: 12rpx;
|
||||
border: 2rpx solid #e2e8f0;
|
||||
}
|
||||
|
||||
.toggle-btn.active {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.toggle-text { font-size: 28rpx; color: #64748b; }
|
||||
.toggle-btn.active .toggle-text { color: #6366f1; font-weight: 600; }
|
||||
|
||||
.price-range { display: flex; align-items: center; gap: 12rpx; }
|
||||
.price-input { flex: 1; }
|
||||
.price-sep { font-size: 28rpx; color: #94a3b8; }
|
||||
|
||||
.tag-selector { display: flex; flex-wrap: wrap; gap: 16rpx; }
|
||||
|
||||
.tag-chip {
|
||||
padding: 10rpx 24rpx;
|
||||
background: #f5f6fa;
|
||||
border-radius: 32rpx;
|
||||
border: 2rpx solid #e2e8f0;
|
||||
}
|
||||
|
||||
.tag-chip.active {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.tag-chip-text { font-size: 26rpx; color: #64748b; }
|
||||
.tag-chip.active .tag-chip-text { color: #6366f1; font-weight: 500; }
|
||||
|
||||
.submit-area { margin-top: 16rpx; }
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
background: #6366f1;
|
||||
color: #ffffff;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
border-radius: 16rpx;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.submit-btn[disabled] { background: #a5b4fc; }
|
||||
.submit-btn::after { border: none; }
|
||||
</style>
|
||||
@@ -0,0 +1,365 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { get } from "@/utils/request";
|
||||
|
||||
const latitude = ref(39.908823);
|
||||
const longitude = ref(116.39747);
|
||||
const address = ref("移动地图选择位置");
|
||||
const currentCity = ref("");
|
||||
const mapCtx = ref(null);
|
||||
|
||||
const keyword = ref("");
|
||||
const searchResults = ref([]);
|
||||
const showResults = ref(false);
|
||||
let searchTimer = null;
|
||||
|
||||
onMounted(() => {
|
||||
mapCtx.value = uni.createMapContext("pickMap");
|
||||
uni.getLocation({
|
||||
type: "gcj02",
|
||||
success: (res) => {
|
||||
latitude.value = res.latitude;
|
||||
longitude.value = res.longitude;
|
||||
reverseGeocode(res.latitude, res.longitude);
|
||||
},
|
||||
fail: () => {},
|
||||
});
|
||||
});
|
||||
|
||||
const onRegionChange = (e) => {
|
||||
if (e.type === "end" || e.detail?.type === "end") {
|
||||
mapCtx.value.getCenterLocation({
|
||||
success: (res) => {
|
||||
latitude.value = res.latitude;
|
||||
longitude.value = res.longitude;
|
||||
reverseGeocode(res.latitude, res.longitude);
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const reverseGeocode = async (lat, lng) => {
|
||||
try {
|
||||
const data = await get("/map/geocoder/reverse", {
|
||||
location: `${lat},${lng}`,
|
||||
});
|
||||
if (data && data.status === 0) {
|
||||
const r = data.result;
|
||||
const poi = r.pois && r.pois.length > 0 ? r.pois[0].title : "";
|
||||
currentCity.value =
|
||||
r.address_component?.city || r.ad_info?.city || "";
|
||||
address.value =
|
||||
poi || r.address || r.formatted_addresses?.recommend || "已定位";
|
||||
}
|
||||
} catch {
|
||||
currentCity.value = "";
|
||||
address.value = `${lat.toFixed(6)}, ${lng.toFixed(6)}`;
|
||||
}
|
||||
};
|
||||
|
||||
const onSearchInput = () => {
|
||||
clearTimeout(searchTimer);
|
||||
if (!keyword.value.trim()) {
|
||||
searchResults.value = [];
|
||||
showResults.value = false;
|
||||
return;
|
||||
}
|
||||
searchTimer = setTimeout(() => {
|
||||
searchPlace(keyword.value.trim());
|
||||
}, 400);
|
||||
};
|
||||
|
||||
const searchPlace = async (kw) => {
|
||||
try {
|
||||
const boundary = `nearby(${latitude.value},${longitude.value},50000)`;
|
||||
const data = await get("/map/place/search", { keyword: kw, boundary });
|
||||
if (data && data.status === 0 && data.data) {
|
||||
searchResults.value = data.data.map((item) => ({
|
||||
title: item.title,
|
||||
address: item.address,
|
||||
lat: item.location.lat,
|
||||
lng: item.location.lng,
|
||||
}));
|
||||
showResults.value = true;
|
||||
} else {
|
||||
searchResults.value = [];
|
||||
showResults.value = false;
|
||||
}
|
||||
} catch {
|
||||
searchResults.value = [];
|
||||
showResults.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const selectResult = (item) => {
|
||||
latitude.value = item.lat;
|
||||
longitude.value = item.lng;
|
||||
address.value = item.title;
|
||||
keyword.value = item.title;
|
||||
showResults.value = false;
|
||||
searchResults.value = [];
|
||||
mapCtx.value.moveToLocation({
|
||||
latitude: item.lat,
|
||||
longitude: item.lng,
|
||||
});
|
||||
};
|
||||
|
||||
const clearSearch = () => {
|
||||
keyword.value = "";
|
||||
searchResults.value = [];
|
||||
showResults.value = false;
|
||||
};
|
||||
|
||||
const confirmLocation = () => {
|
||||
uni.$emit("locationPicked", {
|
||||
latitude: latitude.value,
|
||||
longitude: longitude.value,
|
||||
name: address.value,
|
||||
city: currentCity.value,
|
||||
});
|
||||
uni.navigateBack();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="pick-page">
|
||||
<map
|
||||
id="pickMap"
|
||||
class="pick-map"
|
||||
:latitude="latitude"
|
||||
:longitude="longitude"
|
||||
:scale="16"
|
||||
show-location
|
||||
@regionchange="onRegionChange"
|
||||
/>
|
||||
<view class="center-pin">
|
||||
<image class="pin-icon" src="/static/marker.svg" mode="aspectFit" />
|
||||
</view>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<view class="search-bar">
|
||||
<view class="search-inner">
|
||||
<uni-icons type="search" size="18" color="#94a3b8" />
|
||||
<input
|
||||
v-model="keyword"
|
||||
class="search-input"
|
||||
placeholder="搜索地点"
|
||||
placeholder-class="search-placeholder"
|
||||
confirm-type="search"
|
||||
@input="onSearchInput"
|
||||
@confirm="onSearchInput"
|
||||
/>
|
||||
<view v-if="keyword" class="clear-btn" @tap="clearSearch">
|
||||
<uni-icons type="clear" size="18" color="#94a3b8" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 搜索结果列表 -->
|
||||
<scroll-view
|
||||
v-if="showResults && searchResults.length > 0"
|
||||
class="search-results"
|
||||
scroll-y
|
||||
>
|
||||
<view
|
||||
v-for="(item, idx) in searchResults"
|
||||
:key="idx"
|
||||
class="result-item"
|
||||
@tap="selectResult(item)"
|
||||
>
|
||||
<uni-icons type="location" size="18" color="#6366f1" />
|
||||
<view class="result-info">
|
||||
<text class="result-title">{{ item.title }}</text>
|
||||
<text class="result-addr">{{ item.address }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 底部信息 -->
|
||||
<view class="bottom-bar">
|
||||
<view class="address-info">
|
||||
<uni-icons type="location-filled" size="20" color="#6366f1" />
|
||||
<text class="address-text">{{ address }}</text>
|
||||
</view>
|
||||
<view class="coord-row">
|
||||
<text class="coord-text"
|
||||
>经度: {{ longitude.toFixed(6) }} 纬度:
|
||||
{{ latitude.toFixed(6) }}</text
|
||||
>
|
||||
</view>
|
||||
<button class="confirm-btn" @tap="confirmLocation">确认选点</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pick-page {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.pick-map {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.center-pin {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -100%);
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.pin-icon {
|
||||
width: 40px;
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 24rpx 32rpx;
|
||||
padding-top: calc(24rpx + env(safe-area-inset-top));
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.search-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #ffffff;
|
||||
border-radius: 40rpx;
|
||||
padding: 0 24rpx;
|
||||
height: 80rpx;
|
||||
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.1);
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
color: #1e293b;
|
||||
height: 80rpx;
|
||||
}
|
||||
|
||||
.search-placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
padding: 8rpx;
|
||||
}
|
||||
|
||||
.search-results {
|
||||
position: absolute;
|
||||
top: calc(120rpx + env(safe-area-inset-top));
|
||||
left: 32rpx;
|
||||
right: 32rpx;
|
||||
max-height: 500rpx;
|
||||
background: #ffffff;
|
||||
border-radius: 16rpx;
|
||||
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.12);
|
||||
z-index: 30;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 24rpx;
|
||||
gap: 16rpx;
|
||||
border-bottom: 1rpx solid #f1f5f9;
|
||||
}
|
||||
|
||||
.result-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.result-item:active {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.result-info {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
font-size: 28rpx;
|
||||
color: #1e293b;
|
||||
font-weight: 500;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.result-addr {
|
||||
font-size: 24rpx;
|
||||
color: #94a3b8;
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bottom-bar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #ffffff;
|
||||
border-radius: 24rpx 24rpx 0 0;
|
||||
padding: 32rpx;
|
||||
padding-bottom: calc(32rpx + env(safe-area-inset-bottom));
|
||||
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.08);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.address-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.address-text {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.coord-row {
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.coord-text {
|
||||
font-size: 24rpx;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
width: 100%;
|
||||
height: 84rpx;
|
||||
line-height: 84rpx;
|
||||
background: #6366f1;
|
||||
color: #ffffff;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
border-radius: 16rpx;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.confirm-btn::after {
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user