Initial project commit

This commit is contained in:
2026-05-09 16:40:29 +08:00
commit 02b0259a9e
267 changed files with 54891 additions and 0 deletions
+427
View File
@@ -0,0 +1,427 @@
<script setup>
import { ref, computed } from "vue";
import { onPullDownRefresh, onReachBottom, onShow } from "@dcloudio/uni-app";
import {
getShootingList,
getMyShootings,
getMyApplications,
} from "@/api/shooting";
import { getEventList, getMyEvents, getMyRegistrations } from "@/api/event";
import { checkLogin } from "@/utils/auth";
const tabs = [
{ key: "shooting_plaza", label: "约拍广场", type: "shooting", mine: false },
{ key: "shooting_mine", label: "我的约拍", type: "shooting", mine: true },
{ key: "event_plaza", label: "活动广场", type: "event", mine: false },
{ key: "event_mine", label: "我的活动", type: "event", mine: true },
];
const activeTab = ref("shooting_plaza");
const shootingPlaza = ref({ list: [], page: 1, total: 0, finished: false, loading: false });
const shootingMine = ref({ list: [], page: 1, total: 0, finished: false, loading: false });
const eventPlaza = ref({ list: [], page: 1, total: 0, finished: false, loading: false });
const eventMine = ref({ list: [], page: 1, total: 0, finished: false, loading: false });
const stateMap = {
shooting_plaza: shootingPlaza,
shooting_mine: shootingMine,
event_plaza: eventPlaza,
event_mine: eventMine,
};
const currentState = computed(() => stateMap[activeTab.value].value);
const currentItems = computed(() => currentState.value.list || []);
const isCurrentLoading = computed(() => currentState.value.loading);
const isCurrentFinished = computed(() => currentState.value.finished);
const currentTabMeta = computed(() => tabs.find((t) => t.key === activeTab.value));
const shootingStatusLabels = {
open: "招募中",
matched: "已匹配",
closed: "已关闭",
};
const shootingStatusColors = {
open: "#22c55e",
matched: "#f59e0b",
closed: "#9ca3af",
};
const eventStatusLabels = {
upcoming: "即将开始",
ongoing: "进行中",
ended: "已结束",
cancelled: "已取消",
};
const eventStatusColors = {
upcoming: "#6366f1",
ongoing: "#22c55e",
ended: "#9ca3af",
cancelled: "#ef4444",
};
const roleLabels = {
photographer: "找摄影",
cosplayer: "找Coser",
both: "不限",
};
function formatDateTime(val) {
if (!val) return "时间待定";
const dt = new Date(val);
const y = dt.getFullYear();
const m = String(dt.getMonth() + 1).padStart(2, "0");
const d = String(dt.getDate()).padStart(2, "0");
const hh = String(dt.getHours()).padStart(2, "0");
const mm = String(dt.getMinutes()).padStart(2, "0");
return `${y}-${m}-${d} ${hh}:${mm}`;
}
function formatBudget(item) {
if (item.is_free) return "互免";
if (item.budget_min && item.budget_max) return `${item.budget_min}-${item.budget_max}`;
if (item.budget_min) return `${item.budget_min}`;
if (item.budget_max) return `${item.budget_max}以内`;
return "面议";
}
function ensureLoginForMineTab() {
if (!currentTabMeta.value?.mine) return true;
return checkLogin();
}
async function fetchTab(tabKey, reset = false) {
const stateRef = stateMap[tabKey];
const state = stateRef.value;
if (state.loading) return;
if (!reset && state.finished) return;
const tabMeta = tabs.find((t) => t.key === tabKey);
if (tabMeta?.mine && !checkLogin()) return;
if (reset) {
state.page = 1;
state.total = 0;
state.finished = false;
state.list = [];
}
state.loading = true;
try {
const params = { page: state.page, page_size: 20 };
let res;
if (tabKey === "shooting_plaza") {
params.status = "open";
res = await getShootingList(params);
} else if (tabKey === "shooting_mine") {
const [mineRes, applyRes] = await Promise.all([
getMyShootings(params),
getMyApplications({ page: 1, page_size: 5 }),
]);
const mixed = [...(mineRes.items || [])];
if (state.page === 1) {
(applyRes.items || []).forEach((app) => {
mixed.push({
id: `applied_${app.id}`,
_application: true,
request_id: app.request_id,
title: `我报名的约拍 #${app.request_id}`,
city: "",
status: app.status,
created_at: app.created_at,
message: app.message,
});
});
}
res = { items: mixed, total: (mineRes.total || 0) + (state.page === 1 ? (applyRes.items || []).length : 0) };
} else if (tabKey === "event_plaza") {
res = await getEventList(params);
} else {
const [mineRes, regRes] = await Promise.all([
getMyEvents(params),
getMyRegistrations({ page: 1, page_size: 5 }),
]);
const mixed = [...(mineRes.items || [])];
if (state.page === 1) {
(regRes.items || []).forEach((reg) => {
mixed.push({
id: `joined_${reg.id}`,
_registration: true,
event_id: reg.event_id,
title: `我参加的活动 #${reg.event_id}`,
city: "",
status: reg.status,
created_at: reg.created_at,
});
});
}
res = { items: mixed, total: (mineRes.total || 0) + (state.page === 1 ? (regRes.items || []).length : 0) };
}
const items = res.items || [];
if (reset) state.list = items;
else state.list.push(...items);
state.total = res.total || 0;
state.finished = items.length < 20 || state.list.length >= state.total;
state.page += 1;
} catch (err) {
console.error(err);
} finally {
state.loading = false;
}
}
function switchTab(key) {
activeTab.value = key;
if (!ensureLoginForMineTab()) return;
if (stateMap[key].value.list.length === 0) {
fetchTab(key, true);
}
}
function goCreate() {
if (currentTabMeta.value?.type === "shooting") {
uni.navigateTo({ url: "/pages/shooting/create" });
return;
}
uni.navigateTo({ url: "/pages/event/create" });
}
function goDetail(item) {
if (item._application) {
uni.navigateTo({ url: `/pages/shooting/detail?id=${item.request_id}` });
return;
}
if (item._registration) {
uni.navigateTo({ url: `/pages/event/detail?id=${item.event_id}` });
return;
}
if (currentTabMeta.value?.type === "shooting") {
uni.navigateTo({ url: `/pages/shooting/detail?id=${item.id}` });
return;
}
uni.navigateTo({ url: `/pages/event/detail?id=${item.id}` });
}
onShow(() => {
if (stateMap[activeTab.value].value.list.length === 0) {
fetchTab(activeTab.value, true);
}
});
onPullDownRefresh(async () => {
await fetchTab(activeTab.value, true);
uni.stopPullDownRefresh();
});
onReachBottom(() => {
fetchTab(activeTab.value);
});
</script>
<template>
<view class="activity-page">
<view class="page-header">
<text class="page-title">活动</text>
<view class="create-btn" @tap="goCreate">
<uni-icons type="plusempty" size="16" color="#fff" />
<text>发布</text>
</view>
</view>
<view class="tabs">
<view
v-for="tab in tabs"
:key="tab.key"
class="tab-item"
:class="{ active: activeTab === tab.key }"
@tap="switchTab(tab.key)"
>
{{ tab.label }}
</view>
</view>
<view class="list-wrap">
<view
v-for="item in currentItems"
:key="item.id"
class="card"
@tap="goDetail(item)"
>
<view class="card-top">
<text class="card-title">{{ item.title }}</text>
<view
class="status"
:style="{
background: currentTabMeta.type === 'shooting'
? (shootingStatusColors[item.status] || '#9ca3af')
: (eventStatusColors[item.status] || '#9ca3af')
}"
>
{{ currentTabMeta.type === "shooting" ? (shootingStatusLabels[item.status] || item.status) : (eventStatusLabels[item.status] || item.status) }}
</view>
</view>
<view class="card-row" v-if="item.city">
<uni-icons type="location" size="14" color="#6366f1" />
<text>{{ item.city }}</text>
</view>
<view class="card-row" v-if="currentTabMeta.type === 'shooting' && item.role_needed">
<uni-icons type="person" size="14" color="#6366f1" />
<text>{{ roleLabels[item.role_needed] || item.role_needed }}</text>
<text class="dot">·</text>
<text>{{ formatBudget(item) }}</text>
</view>
<view class="card-row" v-if="item.message">
<uni-icons type="chat" size="14" color="#6366f1" />
<text class="line1">{{ item.message }}</text>
</view>
<view class="card-bottom">
<text>{{ formatDateTime(item.start_time || item.shoot_date || item.created_at) }}</text>
<text v-if="currentTabMeta.type === 'shooting' && !item._application">{{ item.application_count || 0 }}人报名</text>
<text v-if="currentTabMeta.type === 'event' && !item._registration">{{ item.registration_count || 0 }}人报名</text>
</view>
</view>
<view v-if="isCurrentLoading" class="status-tip">加载中...</view>
<view v-else-if="isCurrentFinished && currentItems.length" class="status-tip">没有更多了</view>
<view v-else-if="!isCurrentLoading && !currentItems.length" class="empty-tip">
<uni-icons type="info" size="40" color="#d1d5db" />
<text>暂无内容</text>
</view>
</view>
</view>
</template>
<style scoped>
.activity-page {
min-height: 100vh;
background: #f5f6fa;
padding-bottom: 20rpx;
}
.page-header {
background: #fff;
padding: 20rpx 28rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.page-title {
font-size: 34rpx;
font-weight: 700;
color: #1e1e2e;
}
.create-btn {
display: flex;
align-items: center;
gap: 6rpx;
padding: 10rpx 20rpx;
border-radius: 32rpx;
background: #6366f1;
color: #fff;
font-size: 24rpx;
}
.tabs {
display: flex;
background: #fff;
border-top: 1rpx solid #f1f5f9;
border-bottom: 1rpx solid #e5e7eb;
}
.tab-item {
flex: 1;
text-align: center;
padding: 20rpx 0;
font-size: 26rpx;
color: #6b7280;
position: relative;
}
.tab-item.active {
color: #6366f1;
font-weight: 600;
}
.tab-item.active::after {
content: "";
position: absolute;
left: 26%;
right: 26%;
bottom: 0;
height: 4rpx;
background: #6366f1;
border-radius: 4rpx;
}
.list-wrap {
padding: 0 20rpx;
}
.card {
background: #fff;
border-radius: 20rpx;
padding: 24rpx 28rpx;
margin-top: 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
}
.card-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12rpx;
}
.card-title {
font-size: 30rpx;
font-weight: 600;
color: #1e1e2e;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status {
font-size: 22rpx;
color: #fff;
padding: 4rpx 14rpx;
border-radius: 16rpx;
flex-shrink: 0;
}
.card-row {
margin-top: 12rpx;
display: flex;
align-items: center;
gap: 6rpx;
color: #6b7280;
font-size: 24rpx;
}
.dot {
margin: 0 4rpx;
}
.line1 {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-bottom {
margin-top: 12rpx;
display: flex;
justify-content: space-between;
font-size: 22rpx;
color: #9ca3af;
}
.status-tip {
text-align: center;
font-size: 26rpx;
color: #9ca3af;
padding: 30rpx 0;
}
.empty-tip {
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
padding: 120rpx 0;
font-size: 28rpx;
color: #9ca3af;
}
</style>
+297
View File
@@ -0,0 +1,297 @@
<script setup>
import { ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { createEvent, updateEvent, getEventDetail } from "@/api/event";
import { uploadImage } from "@/api/spot";
import { resolveImageUrl } from "@/utils/image";
const isEdit = ref(false);
const editId = ref(0);
const submitting = ref(false);
const form = ref({
title: "",
city: "",
description: "",
cover_url: "",
location_name: "",
start_date: "",
start_time: "10:00",
end_date: "",
end_time: "18:00",
max_participants: 0,
});
const coverPreview = ref("");
async function chooseCover() {
uni.chooseImage({
count: 1,
success: async (res) => {
const path = res.tempFilePaths[0];
try {
uni.showLoading({ title: "上传中..." });
const uploadRes = await uploadImage(path);
form.value.cover_url = uploadRes.url || uploadRes.path;
coverPreview.value = resolveImageUrl(form.value.cover_url);
uni.showToast({ title: "上传成功", icon: "success" });
} catch (e) {
uni.showToast({ title: "上传失败", icon: "none" });
} finally {
uni.hideLoading();
}
},
});
}
function validate() {
if (!form.value.title.trim()) {
uni.showToast({ title: "请输入活动标题", icon: "none" });
return false;
}
if (!form.value.city.trim()) {
uni.showToast({ title: "请输入城市", icon: "none" });
return false;
}
return true;
}
function buildDateTime(date, time) {
if (!date) return null;
return new Date(`${date}T${time || "00:00"}:00`).toISOString();
}
async function handleSubmit() {
if (!validate()) return;
submitting.value = true;
const data = {
title: form.value.title.trim(),
city: form.value.city.trim(),
description: form.value.description.trim() || null,
cover_url: form.value.cover_url || null,
location_name: form.value.location_name.trim() || null,
max_participants: Number(form.value.max_participants) || 0,
start_time: buildDateTime(form.value.start_date, form.value.start_time),
end_time: buildDateTime(form.value.end_date, form.value.end_time),
};
try {
if (isEdit.value) {
await updateEvent(editId.value, data);
uni.showToast({ title: "更新成功", icon: "success" });
} else {
await createEvent(data);
uni.showToast({ title: "发布成功,等待审核", icon: "success" });
}
setTimeout(() => uni.navigateBack(), 1200);
} catch (e) {
uni.showToast({ title: e.message || "提交失败", icon: "none" });
} finally {
submitting.value = false;
}
}
async function loadForEdit(id) {
try {
const d = await getEventDetail(id);
form.value.title = d.title || "";
form.value.city = d.city || "";
form.value.description = d.description || "";
form.value.cover_url = d.cover_url || "";
form.value.location_name = d.location_name || "";
form.value.max_participants = d.max_participants || 0;
if (d.cover_url) coverPreview.value = resolveImageUrl(d.cover_url);
if (d.start_time) {
const st = new Date(d.start_time);
form.value.start_date = st.toISOString().substring(0, 10);
form.value.start_time = `${String(st.getHours()).padStart(2, "0")}:${String(st.getMinutes()).padStart(2, "0")}`;
}
if (d.end_time) {
const et = new Date(d.end_time);
form.value.end_date = et.toISOString().substring(0, 10);
form.value.end_time = `${String(et.getHours()).padStart(2, "0")}:${String(et.getMinutes()).padStart(2, "0")}`;
}
} catch (e) {
uni.showToast({ title: "加载失败", icon: "none" });
}
}
onLoad((query) => {
if (query.id) {
isEdit.value = true;
editId.value = Number(query.id);
uni.setNavigationBarTitle({ title: "编辑活动" });
loadForEdit(editId.value);
}
});
</script>
<template>
<view class="create-page">
<view class="form-card">
<view class="cover-section" @tap="chooseCover">
<image v-if="coverPreview" class="cover-preview" :src="coverPreview" mode="aspectFill" />
<view v-else class="cover-placeholder">
<uni-icons type="plusempty" size="32" color="#9ca3af" />
<text class="cover-hint">添加封面图</text>
</view>
</view>
<view class="form-row">
<text class="form-label required">活动标题</text>
<input v-model="form.title" class="form-input" placeholder="如:外滩夜景约拍团" :maxlength="100" />
</view>
<view class="form-row">
<text class="form-label required">城市</text>
<input v-model="form.city" class="form-input" placeholder="如:上海" :maxlength="50" />
</view>
<view class="form-row">
<text class="form-label">活动地点</text>
<input v-model="form.location_name" class="form-input" placeholder="如:外滩观景平台" :maxlength="100" />
</view>
<view class="form-row">
<text class="form-label">开始日期</text>
<picker mode="date" :value="form.start_date" @change="form.start_date = $event.detail.value">
<view class="form-input picker-display">{{ form.start_date || "选择日期" }}</view>
</picker>
</view>
<view class="form-row">
<text class="form-label">开始时间</text>
<picker mode="time" :value="form.start_time" @change="form.start_time = $event.detail.value">
<view class="form-input picker-display">{{ form.start_time }}</view>
</picker>
</view>
<view class="form-row">
<text class="form-label">结束日期</text>
<picker mode="date" :value="form.end_date" @change="form.end_date = $event.detail.value">
<view class="form-input picker-display">{{ form.end_date || "选择日期" }}</view>
</picker>
</view>
<view class="form-row">
<text class="form-label">结束时间</text>
<picker mode="time" :value="form.end_time" @change="form.end_time = $event.detail.value">
<view class="form-input picker-display">{{ form.end_time }}</view>
</picker>
</view>
<view class="form-row">
<text class="form-label">人数限制</text>
<input v-model="form.max_participants" class="form-input" type="number" placeholder="0表示不限" />
</view>
<view class="form-row">
<text class="form-label">活动详情</text>
<textarea v-model="form.description" class="form-textarea" placeholder="描述活动内容、注意事项等" :maxlength="5000" />
</view>
</view>
<view class="submit-row">
<view class="submit-btn" :class="{ disabled: submitting }" @tap="handleSubmit">
{{ submitting ? "提交中..." : isEdit ? "保存修改" : "发布活动" }}
</view>
</view>
</view>
</template>
<style scoped>
.create-page {
min-height: 100vh;
background: #f5f6fa;
padding-bottom: 40rpx;
}
.form-card {
background: #fff;
margin: 16rpx 20rpx;
border-radius: 20rpx;
padding: 12rpx 28rpx;
}
.cover-section {
margin: 16rpx 0;
border-radius: 16rpx;
overflow: hidden;
}
.cover-preview {
width: 100%;
height: 300rpx;
}
.cover-placeholder {
width: 100%;
height: 300rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #f3f4f6;
border: 2rpx dashed #d1d5db;
border-radius: 16rpx;
gap: 12rpx;
}
.cover-hint {
font-size: 26rpx;
color: #9ca3af;
}
.form-row {
padding: 20rpx 0;
border-bottom: 1rpx solid #f3f4f6;
}
.form-row:last-child {
border-bottom: none;
}
.form-label {
font-size: 26rpx;
color: #374151;
margin-bottom: 10rpx;
display: block;
}
.form-label.required::before {
content: "* ";
color: #ef4444;
}
.form-input {
width: 100%;
height: 72rpx;
border: 1rpx solid #e5e7eb;
border-radius: 12rpx;
padding: 0 20rpx;
font-size: 28rpx;
box-sizing: border-box;
line-height: 72rpx;
}
.picker-display {
display: flex;
align-items: center;
color: #374151;
}
.form-textarea {
width: 100%;
min-height: 200rpx;
border: 1rpx solid #e5e7eb;
border-radius: 12rpx;
padding: 16rpx 20rpx;
font-size: 28rpx;
box-sizing: border-box;
}
.submit-row {
padding: 20rpx 28rpx;
}
.submit-btn {
text-align: center;
padding: 24rpx 0;
background: #6366f1;
color: #fff;
font-size: 30rpx;
font-weight: 700;
border-radius: 16rpx;
}
.submit-btn.disabled {
opacity: 0.6;
}
</style>
+485
View File
@@ -0,0 +1,485 @@
<script setup>
import { ref, computed } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import {
getEventDetail,
registerEvent,
cancelRegistration,
cancelEvent,
getRegistrations,
addEventPhoto,
} from "@/api/event";
import { uploadImage } from "@/api/spot";
import { resolveImageUrl } from "@/utils/image";
const detail = ref(null);
const loading = ref(true);
const eventId = ref(0);
const isOwner = ref(false);
const registrations = ref([]);
const showRegistrations = ref(false);
const statusLabels = {
upcoming: "即将开始",
ongoing: "进行中",
ended: "已结束",
cancelled: "已取消",
};
const statusColors = {
upcoming: "#6366f1",
ongoing: "#22c55e",
ended: "#9ca3af",
cancelled: "#ef4444",
};
async function loadDetail() {
loading.value = true;
try {
const res = await getEventDetail(eventId.value);
detail.value = res;
const userStr = uni.getStorageSync("userInfo");
if (userStr) {
try {
const u = typeof userStr === "string" ? JSON.parse(userStr) : userStr;
isOwner.value = u.id === res.creator?.id;
} catch {}
}
} catch (e) {
uni.showToast({ title: "加载失败", icon: "none" });
} finally {
loading.value = false;
}
}
async function handleRegister() {
try {
await registerEvent(eventId.value);
uni.showToast({ title: "报名成功", icon: "success" });
await loadDetail();
} catch (e) {
uni.showToast({ title: e.message || "报名失败", icon: "none" });
}
}
async function handleCancelRegistration() {
uni.showModal({
title: "提示",
content: "确定取消报名?",
success: async (r) => {
if (!r.confirm) return;
try {
await cancelRegistration(eventId.value);
uni.showToast({ title: "已取消", icon: "success" });
await loadDetail();
} catch (e) {
uni.showToast({ title: e.message || "操作失败", icon: "none" });
}
},
});
}
async function handleCancel() {
uni.showModal({
title: "提示",
content: "确定取消活动?取消后将通知所有报名用户。",
success: async (r) => {
if (!r.confirm) return;
try {
await cancelEvent(eventId.value);
uni.showToast({ title: "已取消", icon: "success" });
await loadDetail();
} catch (e) {
uni.showToast({ title: e.message || "操作失败", icon: "none" });
}
},
});
}
async function loadRegistrations() {
try {
const res = await getRegistrations(eventId.value);
registrations.value = Array.isArray(res) ? res : res.items || [];
showRegistrations.value = true;
} catch (e) {
uni.showToast({ title: "无权查看", icon: "none" });
}
}
async function handleUploadPhoto() {
uni.chooseImage({
count: 1,
success: async (chooseRes) => {
const tempPath = chooseRes.tempFilePaths[0];
try {
uni.showLoading({ title: "上传中..." });
const uploadRes = await uploadImage(tempPath);
const imageUrl = uploadRes.url || uploadRes.path;
await addEventPhoto(eventId.value, { image_url: imageUrl });
uni.showToast({ title: "上传成功", icon: "success" });
await loadDetail();
} catch (e) {
uni.showToast({ title: "上传失败", icon: "none" });
} finally {
uni.hideLoading();
}
},
});
}
function previewPhotos(idx) {
const urls = (detail.value?.photos || []).map((p) => resolveImageUrl(p.image_url));
uni.previewImage({ urls, current: urls[idx] || urls[0] });
}
function formatDateTime(d) {
if (!d) return "待定";
const dt = new Date(d);
return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, "0")}-${String(dt.getDate()).padStart(2, "0")} ${String(dt.getHours()).padStart(2, "0")}:${String(dt.getMinutes()).padStart(2, "0")}`;
}
const canRegister = computed(() => {
if (!detail.value) return false;
if (detail.value.status !== "upcoming") return false;
if (detail.value.has_registered) return false;
if (isOwner.value) return false;
if (detail.value.max_participants > 0 && detail.value.registration_count >= detail.value.max_participants) return false;
return true;
});
onLoad((query) => {
eventId.value = Number(query.id);
loadDetail();
});
</script>
<template>
<view class="detail-page">
<view v-if="loading" class="loading-tip">加载中...</view>
<template v-else-if="detail">
<image
v-if="detail.cover_url"
class="cover-image"
:src="resolveImageUrl(detail.cover_url)"
mode="aspectFill"
/>
<view class="header-card">
<view class="title-row">
<text class="title">{{ detail.title }}</text>
<view class="status-tag" :style="{ background: statusColors[detail.status] || '#9ca3af' }">
{{ statusLabels[detail.status] || detail.status }}
</view>
</view>
<view class="creator-row" v-if="detail.creator">
<text class="creator-nick">{{ detail.creator.nickname }}</text>
</view>
</view>
<view class="info-card">
<view class="info-row">
<text class="info-label">城市</text>
<text class="info-value">{{ detail.city }}</text>
</view>
<view class="info-row" v-if="detail.location_name">
<text class="info-label">地点</text>
<text class="info-value">{{ detail.location_name }}</text>
</view>
<view class="info-row">
<text class="info-label">开始时间</text>
<text class="info-value">{{ formatDateTime(detail.start_time) }}</text>
</view>
<view class="info-row">
<text class="info-label">结束时间</text>
<text class="info-value">{{ formatDateTime(detail.end_time) }}</text>
</view>
<view class="info-row">
<text class="info-label">人数限制</text>
<text class="info-value">{{ detail.max_participants > 0 ? detail.max_participants + '人' : '不限' }}</text>
</view>
<view class="info-row">
<text class="info-label">已报名</text>
<text class="info-value">{{ detail.registration_count }}</text>
</view>
</view>
<view v-if="detail.description" class="desc-card">
<text class="desc-title">活动详情</text>
<text class="desc-text">{{ detail.description }}</text>
</view>
<view v-if="detail.reject_reason" class="reject-card">
<uni-icons type="info" size="16" color="#ef4444" />
<text class="reject-text">驳回原因{{ detail.reject_reason }}</text>
</view>
<!-- Registration list for owner -->
<view v-if="isOwner" class="section">
<view class="section-header" @tap="loadRegistrations">
<text class="section-title">报名列表</text>
<uni-icons type="right" size="16" color="#6366f1" />
</view>
<view v-if="showRegistrations" class="reg-list">
<view v-if="registrations.length === 0" class="reg-empty">暂无报名</view>
<view v-for="reg in registrations" :key="reg.id" class="reg-item">
<text class="reg-name">{{ reg.user?.nickname || '匿名' }}</text>
<text class="reg-time">{{ formatDateTime(reg.created_at) }}</text>
</view>
</view>
</view>
<!-- Photo album -->
<view class="section">
<view class="section-header">
<text class="section-title">活动相册{{ detail.photos?.length || 0 }}</text>
<view
v-if="detail.has_registered || isOwner"
class="upload-btn"
@tap="handleUploadPhoto"
>
<uni-icons type="plusempty" size="14" color="#6366f1" />
<text>上传</text>
</view>
</view>
<view v-if="detail.photos && detail.photos.length > 0" class="photo-grid">
<image
v-for="(photo, idx) in detail.photos"
:key="photo.id"
class="photo-item"
:src="resolveImageUrl(photo.image_url)"
mode="aspectFill"
@tap="previewPhotos(idx)"
/>
</view>
<view v-else class="photo-empty">
<text>暂无照片</text>
</view>
</view>
<!-- Bottom actions -->
<view class="bottom-bar">
<template v-if="isOwner">
<view
v-if="detail.status !== 'cancelled' && detail.status !== 'ended'"
class="btn btn-cancel"
@tap="handleCancel"
>取消活动</view>
<view
class="btn btn-edit"
@tap="uni.navigateTo({ url: `/pages/event/create?id=${detail.id}` })"
v-if="detail.status === 'upcoming'"
>编辑</view>
</template>
<template v-else>
<view v-if="canRegister" class="btn btn-register" @tap="handleRegister">
我要报名
</view>
<view
v-else-if="detail.has_registered"
class="btn btn-unregister"
@tap="handleCancelRegistration"
>取消报名</view>
<view v-else class="btn btn-disabled">
{{ detail.status === 'cancelled' ? '已取消' : detail.status === 'ended' ? '已结束' : '人数已满' }}
</view>
</template>
</view>
</template>
</view>
</template>
<style scoped>
.detail-page {
min-height: 100vh;
background: #f5f6fa;
padding-bottom: 140rpx;
}
.loading-tip {
text-align: center;
padding: 100rpx 0;
font-size: 28rpx;
color: #9ca3af;
}
.cover-image {
width: 100%;
height: 360rpx;
}
.header-card,
.info-card,
.desc-card,
.reject-card,
.section {
background: #fff;
margin: 16rpx 20rpx 0;
border-radius: 20rpx;
padding: 28rpx;
}
.title-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.title {
font-size: 34rpx;
font-weight: 700;
color: #1e1e2e;
flex: 1;
}
.status-tag {
font-size: 22rpx;
color: #fff;
padding: 4rpx 16rpx;
border-radius: 20rpx;
flex-shrink: 0;
margin-left: 12rpx;
}
.creator-row {
margin-top: 12rpx;
}
.creator-nick {
font-size: 26rpx;
color: #6366f1;
}
.info-row {
display: flex;
align-items: center;
padding: 14rpx 0;
border-bottom: 1rpx solid #f3f4f6;
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
width: 160rpx;
font-size: 26rpx;
color: #9ca3af;
flex-shrink: 0;
}
.info-value {
flex: 1;
font-size: 26rpx;
color: #374151;
}
.desc-title,
.section-title {
font-size: 28rpx;
font-weight: 600;
color: #1e1e2e;
margin-bottom: 12rpx;
}
.desc-text {
font-size: 26rpx;
color: #4b5563;
line-height: 1.7;
white-space: pre-wrap;
}
.reject-card {
display: flex;
align-items: flex-start;
gap: 8rpx;
background: #fef2f2;
}
.reject-text {
font-size: 26rpx;
color: #ef4444;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.upload-btn {
display: flex;
align-items: center;
gap: 6rpx;
font-size: 24rpx;
color: #6366f1;
}
.reg-list {
margin-top: 12rpx;
}
.reg-empty {
text-align: center;
font-size: 26rpx;
color: #9ca3af;
padding: 16rpx 0;
}
.reg-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12rpx 0;
border-top: 1rpx solid #f3f4f6;
}
.reg-name {
font-size: 26rpx;
color: #1e1e2e;
}
.reg-time {
font-size: 22rpx;
color: #9ca3af;
}
.photo-grid {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 8rpx;
}
.photo-item {
width: calc(33.33% - 8rpx);
height: 200rpx;
border-radius: 12rpx;
}
.photo-empty {
text-align: center;
font-size: 26rpx;
color: #9ca3af;
padding: 20rpx 0;
}
.bottom-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: 16rpx;
padding: 20rpx 28rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
background: #fff;
box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.06);
}
.btn {
flex: 1;
text-align: center;
padding: 22rpx 0;
border-radius: 16rpx;
font-size: 28rpx;
font-weight: 600;
}
.btn-register {
background: #6366f1;
color: #fff;
}
.btn-unregister {
background: #fef3c7;
color: #d97706;
}
.btn-cancel {
background: #fee2e2;
color: #ef4444;
}
.btn-edit {
background: #6366f1;
color: #fff;
}
.btn-disabled {
background: #e5e7eb;
color: #9ca3af;
}
</style>
+306
View File
@@ -0,0 +1,306 @@
<script setup>
import { ref } from "vue";
import { onPullDownRefresh, onReachBottom } from "@dcloudio/uni-app";
import { getEventList } from "@/api/event";
import { resolveImageUrl } from "@/utils/image";
const list = ref([]);
const page = ref(1);
const total = ref(0);
const loading = ref(false);
const finished = ref(false);
const city = ref("");
const statusFilter = ref("");
const statusLabels = {
upcoming: "即将开始",
ongoing: "进行中",
ended: "已结束",
cancelled: "已取消",
};
const statusColors = {
upcoming: "#6366f1",
ongoing: "#22c55e",
ended: "#9ca3af",
cancelled: "#ef4444",
};
const statusTabs = [
{ label: "全部", value: "" },
{ label: "即将开始", value: "upcoming" },
{ label: "进行中", value: "ongoing" },
{ label: "已结束", value: "ended" },
];
async function fetchList(reset = false) {
if (loading.value) return;
if (reset) {
page.value = 1;
finished.value = false;
list.value = [];
}
loading.value = true;
try {
const params = { page: page.value, page_size: 20 };
if (city.value) params.city = city.value;
if (statusFilter.value) params.status = statusFilter.value;
const res = await getEventList(params);
const items = res.items || [];
if (reset) list.value = items;
else list.value.push(...items);
total.value = res.total || 0;
if (list.value.length >= total.value) finished.value = true;
page.value++;
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
}
function switchStatus(val) {
statusFilter.value = val;
fetchList(true);
}
function goDetail(id) {
uni.navigateTo({ url: `/pages/event/detail?id=${id}` });
}
function goCreate() {
uni.navigateTo({ url: "/pages/event/create" });
}
function formatDate(d) {
if (!d) return "待定";
const dt = new Date(d);
return `${dt.getMonth() + 1}${dt.getDate()}${String(dt.getHours()).padStart(2, "0")}:${String(dt.getMinutes()).padStart(2, "0")}`;
}
function participantText(item) {
if (item.max_participants > 0) return `${item.registration_count}/${item.max_participants}`;
return `${item.registration_count}人报名`;
}
onPullDownRefresh(async () => {
await fetchList(true);
uni.stopPullDownRefresh();
});
onReachBottom(() => {
if (!finished.value) fetchList();
});
fetchList(true);
</script>
<template>
<view class="event-page">
<view class="top-bar">
<view class="bar-left">
<text class="bar-title">活动</text>
</view>
<view class="bar-btn primary" @tap="goCreate">
<uni-icons type="plusempty" size="16" color="#fff" />
<text>发布</text>
</view>
</view>
<view class="status-tabs">
<view
v-for="tab in statusTabs"
:key="tab.value"
class="status-tab"
:class="{ active: statusFilter === tab.value }"
@tap="switchStatus(tab.value)"
>{{ tab.label }}</view>
</view>
<view class="card-list">
<view
v-for="item in list"
:key="item.id"
class="event-card"
@tap="goDetail(item.id)"
>
<image
v-if="item.cover_url"
class="card-cover"
:src="resolveImageUrl(item.cover_url)"
mode="aspectFill"
/>
<view class="card-body">
<view class="card-title-row">
<text class="card-title">{{ item.title }}</text>
<view class="status-tag" :style="{ background: statusColors[item.status] || '#9ca3af' }">
{{ statusLabels[item.status] || item.status }}
</view>
</view>
<view class="card-info">
<view class="info-item">
<uni-icons type="location" size="14" color="#6366f1" />
<text>{{ item.city }}</text>
<text v-if="item.location_name"> · {{ item.location_name }}</text>
</view>
<view class="info-item">
<uni-icons type="calendar" size="14" color="#6366f1" />
<text>{{ formatDate(item.start_time) }}</text>
</view>
</view>
<view class="card-footer">
<text class="creator-name" v-if="item.creator">{{ item.creator.nickname }}</text>
<text class="participant-count">{{ participantText(item) }}</text>
</view>
</view>
</view>
</view>
<view v-if="loading" class="loading-tip">加载中...</view>
<view v-else-if="finished && list.length > 0" class="loading-tip">没有更多了</view>
<view v-else-if="!loading && list.length === 0" class="empty-tip">
<uni-icons type="info" size="40" color="#d1d5db" />
<text>暂无活动</text>
</view>
</view>
</template>
<style scoped>
.event-page {
min-height: 100vh;
background: #f5f6fa;
padding-bottom: 30rpx;
}
.top-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 28rpx;
background: #fff;
}
.bar-title {
font-size: 34rpx;
font-weight: 700;
color: #1e1e2e;
}
.bar-btn.primary {
display: flex;
align-items: center;
gap: 6rpx;
padding: 10rpx 20rpx;
border-radius: 32rpx;
font-size: 24rpx;
background: #6366f1;
color: #fff;
}
.status-tabs {
display: flex;
background: #fff;
padding: 0 20rpx;
border-bottom: 1rpx solid #e5e7eb;
}
.status-tab {
padding: 18rpx 24rpx;
font-size: 26rpx;
color: #6b7280;
position: relative;
}
.status-tab.active {
color: #6366f1;
font-weight: 600;
}
.status-tab.active::after {
content: "";
position: absolute;
left: 20%;
right: 20%;
bottom: 0;
height: 4rpx;
background: #6366f1;
border-radius: 4rpx;
}
.card-list {
padding: 0 20rpx;
}
.event-card {
background: #fff;
border-radius: 20rpx;
margin-top: 16rpx;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
}
.card-cover {
width: 100%;
height: 280rpx;
}
.card-body {
padding: 24rpx 28rpx;
}
.card-title-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.card-title {
font-size: 30rpx;
font-weight: 600;
color: #1e1e2e;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-tag {
font-size: 22rpx;
color: #fff;
padding: 4rpx 16rpx;
border-radius: 20rpx;
flex-shrink: 0;
margin-left: 12rpx;
}
.card-info {
margin-top: 12rpx;
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.info-item {
display: flex;
align-items: center;
gap: 6rpx;
font-size: 24rpx;
color: #6b7280;
}
.card-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 14rpx;
}
.creator-name {
font-size: 24rpx;
color: #6366f1;
}
.participant-count {
font-size: 22rpx;
color: #9ca3af;
}
.loading-tip {
text-align: center;
font-size: 26rpx;
color: #9ca3af;
padding: 30rpx 0;
}
.empty-tip {
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
padding: 120rpx 0;
font-size: 28rpx;
color: #9ca3af;
}
</style>
+291
View File
@@ -0,0 +1,291 @@
<script setup>
import { ref } from "vue";
import { onPullDownRefresh, onReachBottom } from "@dcloudio/uni-app";
import { getMyEvents, getMyRegistrations } from "@/api/event";
const tab = ref("created");
const createdList = ref([]);
const joinedList = ref([]);
const createdPage = ref(1);
const joinedPage = ref(1);
const createdTotal = ref(0);
const joinedTotal = ref(0);
const createdFinished = ref(false);
const joinedFinished = ref(false);
const loading = ref(false);
const statusLabels = {
upcoming: "即将开始",
ongoing: "进行中",
ended: "已结束",
cancelled: "已取消",
};
const statusColors = {
upcoming: "#6366f1",
ongoing: "#22c55e",
ended: "#9ca3af",
cancelled: "#ef4444",
};
const auditLabels = {
pending: "待审核",
approved: "已通过",
rejected: "已驳回",
};
const auditColors = {
pending: "#f59e0b",
approved: "#22c55e",
rejected: "#ef4444",
};
async function fetchCreated(reset = false) {
if (loading.value) return;
if (reset) {
createdPage.value = 1;
createdFinished.value = false;
createdList.value = [];
}
loading.value = true;
try {
const res = await getMyEvents({ page: createdPage.value, page_size: 20 });
const items = res.items || [];
if (reset) createdList.value = items;
else createdList.value.push(...items);
createdTotal.value = res.total || 0;
if (createdList.value.length >= createdTotal.value) createdFinished.value = true;
createdPage.value++;
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
}
async function fetchJoined(reset = false) {
if (loading.value) return;
if (reset) {
joinedPage.value = 1;
joinedFinished.value = false;
joinedList.value = [];
}
loading.value = true;
try {
const res = await getMyRegistrations({ page: joinedPage.value, page_size: 20 });
const items = res.items || [];
if (reset) joinedList.value = items;
else joinedList.value.push(...items);
joinedTotal.value = res.total || 0;
if (joinedList.value.length >= joinedTotal.value) joinedFinished.value = true;
joinedPage.value++;
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
}
function switchTab(t) {
tab.value = t;
if (t === "created" && createdList.value.length === 0) fetchCreated(true);
if (t === "joined" && joinedList.value.length === 0) fetchJoined(true);
}
function goDetail(id) {
uni.navigateTo({ url: `/pages/event/detail?id=${id}` });
}
function formatDate(d) {
if (!d) return "";
const dt = new Date(d);
return `${dt.getMonth() + 1}${dt.getDate()}`;
}
onPullDownRefresh(async () => {
if (tab.value === "created") await fetchCreated(true);
else await fetchJoined(true);
uni.stopPullDownRefresh();
});
onReachBottom(() => {
if (tab.value === "created" && !createdFinished.value) fetchCreated();
if (tab.value === "joined" && !joinedFinished.value) fetchJoined();
});
fetchCreated(true);
</script>
<template>
<view class="mine-event-page">
<view class="tabs">
<view class="tab-item" :class="{ active: tab === 'created' }" @tap="switchTab('created')">
我发布的
</view>
<view class="tab-item" :class="{ active: tab === 'joined' }" @tap="switchTab('joined')">
我参加的
</view>
</view>
<view v-if="tab === 'created'" class="card-list">
<view
v-for="item in createdList"
:key="item.id"
class="ev-card"
@tap="goDetail(item.id)"
>
<view class="card-title-row">
<text class="card-title">{{ item.title }}</text>
<view class="dual-tags">
<view class="mini-tag" :style="{ background: auditColors[item.audit_status] || '#9ca3af' }">
{{ auditLabels[item.audit_status] || item.audit_status }}
</view>
<view class="mini-tag" :style="{ background: statusColors[item.status] || '#9ca3af' }">
{{ statusLabels[item.status] || item.status }}
</view>
</view>
</view>
<view class="card-sub">
<text>{{ item.city }}</text>
<text v-if="item.location_name"> · {{ item.location_name }}</text>
</view>
<view class="card-bottom">
<text class="card-date">{{ formatDate(item.start_time) }}</text>
<text class="reg-count">{{ item.registration_count }}人报名</text>
</view>
</view>
<view v-if="loading" class="loading-tip">加载中...</view>
<view v-else-if="createdFinished && createdList.length" class="loading-tip">没有更多了</view>
<view v-else-if="!loading && !createdList.length" class="empty-tip">
<uni-icons type="info" size="40" color="#d1d5db" />
<text>还没有发布活动</text>
</view>
</view>
<view v-if="tab === 'joined'" class="card-list">
<view
v-for="reg in joinedList"
:key="reg.id"
class="ev-card"
@tap="goDetail(reg.event_id)"
>
<view class="card-title-row">
<text class="card-title">活动 #{{ reg.event_id }}</text>
<view class="mini-tag" style="background: #22c55e">{{ reg.status === 'registered' ? '已报名' : reg.status }}</view>
</view>
<view class="card-bottom">
<text class="card-date">{{ formatDate(reg.created_at) }}</text>
</view>
</view>
<view v-if="loading" class="loading-tip">加载中...</view>
<view v-else-if="joinedFinished && joinedList.length" class="loading-tip">没有更多了</view>
<view v-else-if="!loading && !joinedList.length" class="empty-tip">
<uni-icons type="info" size="40" color="#d1d5db" />
<text>还没有参加活动</text>
</view>
</view>
</view>
</template>
<style scoped>
.mine-event-page {
min-height: 100vh;
background: #f5f6fa;
}
.tabs {
display: flex;
background: #fff;
border-bottom: 1rpx solid #e5e7eb;
}
.tab-item {
flex: 1;
text-align: center;
padding: 24rpx 0;
font-size: 28rpx;
color: #6b7280;
position: relative;
}
.tab-item.active {
color: #6366f1;
font-weight: 600;
}
.tab-item.active::after {
content: "";
position: absolute;
left: 30%;
right: 30%;
bottom: 0;
height: 4rpx;
background: #6366f1;
border-radius: 4rpx;
}
.card-list {
padding: 0 20rpx;
}
.ev-card {
background: #fff;
border-radius: 20rpx;
padding: 24rpx 28rpx;
margin-top: 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
}
.card-title-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.card-title {
font-size: 28rpx;
font-weight: 600;
color: #1e1e2e;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dual-tags {
display: flex;
gap: 8rpx;
flex-shrink: 0;
margin-left: 12rpx;
}
.mini-tag {
font-size: 20rpx;
color: #fff;
padding: 2rpx 12rpx;
border-radius: 16rpx;
}
.card-sub {
font-size: 24rpx;
color: #6b7280;
margin-top: 10rpx;
}
.card-bottom {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 12rpx;
}
.card-date {
font-size: 22rpx;
color: #9ca3af;
}
.reg-count {
font-size: 22rpx;
color: #6366f1;
}
.loading-tip {
text-align: center;
font-size: 26rpx;
color: #9ca3af;
padding: 30rpx 0;
}
.empty-tip {
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
padding: 120rpx 0;
font-size: 28rpx;
color: #9ca3af;
}
</style>
File diff suppressed because it is too large Load Diff
+301
View File
@@ -0,0 +1,301 @@
<script setup>
import { ref } from "vue";
import { login, register } from "@/api/auth";
import { useUserStore } from "@/store/user";
const userStore = useUserStore();
const isRegister = ref(false);
const activeTab = ref("phone");
const form = ref({
phone: "",
email: "",
password: "",
nickname: "",
});
const loading = ref(false);
const validate = () => {
const account =
activeTab.value === "phone" ? form.value.phone : form.value.email;
if (!account) {
uni.showToast({
title: activeTab.value === "phone" ? "请输入手机号" : "请输入邮箱",
icon: "none",
});
return false;
}
if (!form.value.password) {
uni.showToast({ title: "请输入密码", icon: "none" });
return false;
}
if (form.value.password.length < 6) {
uni.showToast({ title: "密码至少6位", icon: "none" });
return false;
}
if (isRegister.value && !form.value.nickname) {
uni.showToast({ title: "请输入昵称", icon: "none" });
return false;
}
return true;
};
const handleSubmit = async () => {
if (!validate()) return;
loading.value = true;
try {
let res;
if (isRegister.value) {
res = await register({
password: form.value.password,
nickname: form.value.nickname,
...(activeTab.value === "phone"
? { phone: form.value.phone }
: { email: form.value.email }),
});
} else {
const account =
activeTab.value === "phone" ? form.value.phone : form.value.email;
res = await login({ account, password: form.value.password });
}
userStore.setTokens(res.access_token, res.refresh_token);
await userStore.fetchUserInfo();
uni.showToast({
title: isRegister.value ? "注册成功" : "登录成功",
icon: "success",
});
setTimeout(() => {
uni.switchTab({ url: "/pages/index/index" });
}, 500);
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
};
const toggleMode = () => {
isRegister.value = !isRegister.value;
};
</script>
<template>
<view class="login-page">
<view class="header">
<view class="logo-circle">
<text class="logo-text"></text>
</view>
<text class="app-title">次元取景器</text>
<text class="app-subtitle">发现最美二次元取景地</text>
</view>
<view class="card">
<view class="tabs">
<view
class="tab-item"
:class="{ active: activeTab === 'phone' }"
@tap="activeTab = 'phone'"
>
手机号登录
</view>
<view
class="tab-item"
:class="{ active: activeTab === 'email' }"
@tap="activeTab = 'email'"
>
邮箱登录
</view>
</view>
<view class="form">
<view v-if="isRegister" class="field">
<input
v-model="form.nickname"
class="input"
placeholder="请输入昵称"
placeholder-class="placeholder"
/>
</view>
<view v-if="activeTab === 'phone'" class="field">
<input
v-model="form.phone"
class="input"
type="number"
maxlength="11"
placeholder="请输入手机号"
placeholder-class="placeholder"
/>
</view>
<view v-else class="field">
<input
v-model="form.email"
class="input"
placeholder="请输入邮箱"
placeholder-class="placeholder"
/>
</view>
<view class="field">
<input
v-model="form.password"
class="input"
password
placeholder="请输入密码"
placeholder-class="placeholder"
/>
</view>
<button class="submit-btn" :loading="loading" @tap="handleSubmit">
{{ isRegister ? "注册" : "登录" }}
</button>
</view>
<view class="toggle" @tap="toggleMode">
<text class="toggle-text">
{{ isRegister ? "已有账号?立即登录" : "没有账号?立即注册" }}
</text>
</view>
</view>
</view>
</template>
<style scoped>
.login-page {
min-height: 100vh;
background: linear-gradient(135deg, #6366f1 0%, #a78bfa 100%);
padding: 0 40rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.header {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 160rpx;
padding-bottom: 60rpx;
}
.logo-circle {
width: 140rpx;
height: 140rpx;
border-radius: 70rpx;
background: rgba(255, 255, 255, 0.25);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24rpx;
}
.logo-text {
font-size: 64rpx;
color: #ffffff;
font-weight: bold;
}
.app-title {
font-size: 48rpx;
color: #ffffff;
font-weight: bold;
margin-bottom: 12rpx;
}
.app-subtitle {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.8);
}
.card {
width: 100%;
background: #ffffff;
border-radius: 24rpx;
padding: 48rpx 40rpx;
box-shadow: 0 8rpx 40rpx rgba(0, 0, 0, 0.1);
}
.tabs {
display: flex;
margin-bottom: 48rpx;
border-bottom: 2rpx solid #e2e8f0;
}
.tab-item {
flex: 1;
text-align: center;
padding-bottom: 20rpx;
font-size: 30rpx;
color: #64748b;
position: relative;
}
.tab-item.active {
color: #6366f1;
font-weight: 600;
}
.tab-item.active::after {
content: "";
position: absolute;
bottom: -2rpx;
left: 50%;
transform: translateX(-50%);
width: 80rpx;
height: 4rpx;
background: #6366f1;
border-radius: 2rpx;
}
.form {
margin-bottom: 32rpx;
}
.field {
margin-bottom: 28rpx;
}
.input {
width: 100%;
height: 88rpx;
background: #f5f6fa;
border-radius: 16rpx;
padding: 0 28rpx;
font-size: 30rpx;
color: #1e293b;
box-sizing: border-box;
}
.placeholder {
color: #94a3b8;
}
.submit-btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
background: #6366f1;
color: #ffffff;
font-size: 32rpx;
font-weight: 600;
border-radius: 16rpx;
border: none;
margin-top: 12rpx;
}
.submit-btn::after {
border: none;
}
.toggle {
text-align: center;
padding-top: 8rpx;
}
.toggle-text {
font-size: 26rpx;
color: #6366f1;
}
</style>
+133
View File
@@ -0,0 +1,133 @@
<script setup>
import { ref } from "vue";
import { onPullDownRefresh, onReachBottom, onShow } from "@dcloudio/uni-app";
import { getFavorites } from "@/api/favorite";
import { extractList } from "@/utils/request";
import { checkLogin } from "@/utils/auth";
import SpotCard from "@/components/spot-card/spot-card.vue";
onShow(() => { checkLogin(); });
const favorites = ref([]);
const page = ref(1);
const pageSize = 10;
const hasMore = ref(true);
const loading = ref(false);
const fetchFavorites = async (reset = false) => {
if (loading.value) return;
if (!reset && !hasMore.value) return;
loading.value = true;
if (reset) {
page.value = 1;
hasMore.value = true;
}
try {
const res = await getFavorites({ page: page.value, page_size: pageSize });
const list = extractList(res);
if (reset) {
favorites.value = list;
} else {
favorites.value.push(...list);
}
if (list.length < pageSize) {
hasMore.value = false;
} else {
page.value++;
}
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
};
const goDetail = (item) => {
const id = item.spot_id || item.spot?.id || item.id;
if (id) {
uni.navigateTo({ url: `/pages/spot/detail?id=${id}` });
}
};
onPullDownRefresh(async () => {
await fetchFavorites(true);
uni.stopPullDownRefresh();
});
onReachBottom(() => {
fetchFavorites();
});
fetchFavorites(true);
</script>
<template>
<view class="favorites-page">
<view class="list">
<SpotCard
v-for="item in favorites"
:key="item.id"
:spot="item.spot || item"
@click="goDetail(item)"
/>
<view v-if="loading" class="status-tip">
<text>加载中...</text>
</view>
<view v-else-if="!hasMore && favorites.length > 0" class="status-tip">
<text>没有更多了</text>
</view>
<view v-else-if="!loading && favorites.length === 0" class="empty-state">
<uni-icons type="heart-filled" size="48" color="#6366f1" class="empty-icon" />
<text class="empty-title">暂无收藏</text>
<text class="empty-desc">去发现页浏览并收藏喜欢的取景地吧</text>
</view>
</view>
</view>
</template>
<style scoped>
.favorites-page {
min-height: 100vh;
background: #f5f6fa;
}
.list {
padding: 24rpx 32rpx;
}
.status-tip {
text-align: center;
padding: 40rpx 0;
color: #94a3b8;
font-size: 26rpx;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 160rpx 0;
}
.empty-icon {
font-size: 96rpx;
margin-bottom: 24rpx;
}
.empty-title {
font-size: 32rpx;
font-weight: 600;
color: #1e293b;
margin-bottom: 12rpx;
}
.empty-desc {
font-size: 26rpx;
color: #94a3b8;
}
</style>
+403
View File
@@ -0,0 +1,403 @@
<script setup>
import { ref, computed } from "vue";
import { useUserStore } from "@/store/user";
import { resolveImageUrl } from "@/utils/image";
import { getMyStats } from "@/api/user";
import { getUnreadCount } from "@/api/notification";
const userStore = useUserStore();
const isLoggedIn = computed(() => userStore.isLoggedIn);
const user = computed(() => userStore.userInfo);
const stats = ref({ spot_count: 0, approved_count: 0, favorite_count: 0, rating_received: 0 });
const unreadCount = ref(0);
const goLogin = () => {
uni.navigateTo({ url: "/pages/login/index" });
};
const handleLogout = () => {
uni.showModal({
title: "提示",
content: "确定要退出登录吗?",
success: (res) => {
if (res.confirm) {
userStore.logout();
}
},
});
};
const menuItems = [
{ label: "编辑资料", icon: "gear-filled", action: "profile" },
{ label: "我的收藏", icon: "heart-filled", action: "favorites" },
{ label: "我的投稿", icon: "location-filled", action: "my-spots" },
{ label: "约拍/活动", icon: "calendar-filled", action: "activity-hub" },
{ label: "会员中心", icon: "vip-filled", action: "membership" },
{ label: "积分概览", icon: "star-filled", action: "points" },
{ label: "消息通知", icon: "chat-filled", action: "notifications" },
{ label: "设置", icon: "gear", action: "settings" },
];
const handleMenu = (action) => {
switch (action) {
case "profile":
uni.navigateTo({ url: "/pages/mine/profile" });
break;
case "favorites":
uni.navigateTo({ url: "/pages/mine/favorites" });
break;
case "my-spots":
uni.navigateTo({ url: "/pages/mine/my-spots" });
break;
case "points":
uni.navigateTo({ url: "/pages/mine/points" });
break;
case "activity-hub":
uni.switchTab({ url: "/pages/activity/index" });
break;
case "membership":
uni.navigateTo({ url: "/pages/mine/membership" });
break;
case "notifications":
uni.switchTab({ url: "/pages/mine/notifications" });
break;
case "settings":
uni.navigateTo({ url: "/pages/mine/settings" });
break;
}
};
import { onShow } from "@dcloudio/uni-app";
onShow(async () => {
if (isLoggedIn.value) {
userStore.fetchUserInfo();
try {
const s = await getMyStats();
stats.value = s;
} catch (e) { /* ignore */ }
try {
const r = await getUnreadCount();
unreadCount.value = r.count || 0;
} catch (e) { /* ignore */ }
}
});
</script>
<template>
<view class="mine-page">
<view v-if="isLoggedIn" class="user-card">
<view class="avatar-area">
<image
v-if="user?.avatar_url"
class="avatar"
:src="resolveImageUrl(user.avatar_url)"
mode="aspectFill"
/>
<view v-else class="avatar avatar-default">
<uni-icons type="person" size="28" color="#ffffff" class="avatar-icon" />
</view>
</view>
<view class="user-info">
<text class="nickname">{{ user?.nickname || "加载中..." }}</text>
<text v-if="user?.bio" class="user-bio">{{ user.bio }}</text>
<view class="user-meta">
<view v-if="user?.city" class="meta-item"><uni-icons type="location" size="14" color="rgba(255,255,255,0.8)" /> {{ user.city }}</view>
<view v-if="user?.identity" class="identity-badge">
<text class="identity-text">{{ user.identity }}</text>
</view>
</view>
</view>
</view>
<view v-else class="login-card">
<view class="login-avatar">
<uni-icons type="person" size="28" color="#6366f1" class="login-avatar-icon" />
</view>
<text class="login-hint">请先登录</text>
<button class="login-btn" @tap="goLogin">立即登录</button>
</view>
<view v-if="isLoggedIn" class="stats-card">
<view class="stat-item" @tap="handleMenu('my-spots')">
<text class="stat-num">{{ stats.spot_count }}</text>
<text class="stat-label">投稿</text>
</view>
<view class="stat-item" @tap="handleMenu('favorites')">
<text class="stat-num">{{ stats.favorite_count }}</text>
<text class="stat-label">收藏</text>
</view>
<view class="stat-item">
<text class="stat-num">{{ stats.rating_received }}</text>
<text class="stat-label">获赞</text>
</view>
<view class="stat-item">
<text class="stat-num">{{ stats.approved_count }}</text>
<text class="stat-label">通过</text>
</view>
</view>
<view class="menu-card">
<view
v-for="(item, idx) in menuItems"
:key="idx"
class="menu-item"
@tap="handleMenu(item.action)"
>
<view class="menu-left">
<uni-icons :type="item.icon" size="22" color="#6366f1" class="menu-icon" />
<text class="menu-label">{{ item.label }}</text>
</view>
<view class="menu-right">
<view v-if="item.action === 'notifications' && unreadCount > 0" class="badge">
<text class="badge-text">{{ unreadCount > 99 ? '99+' : unreadCount }}</text>
</view>
<uni-icons type="right" size="16" color="#cbd5e1" class="menu-arrow" />
</view>
</view>
</view>
<view v-if="isLoggedIn" class="logout-area">
<button class="logout-btn" @tap="handleLogout">退出登录</button>
</view>
</view>
</template>
<style scoped>
.mine-page {
min-height: 100vh;
background: #f5f6fa;
padding: 0 32rpx;
padding-top: 32rpx;
}
.user-card {
background: linear-gradient(135deg, #6366f1, #818cf8);
border-radius: 24rpx;
padding: 40rpx 32rpx;
display: flex;
align-items: center;
margin-bottom: 32rpx;
}
.avatar-area {
margin-right: 24rpx;
}
.avatar {
width: 120rpx;
height: 120rpx;
border-radius: 60rpx;
border: 4rpx solid rgba(255, 255, 255, 0.3);
}
.avatar-default {
background: rgba(255, 255, 255, 0.25);
display: flex;
align-items: center;
justify-content: center;
}
.avatar-icon {
font-size: 56rpx;
}
.user-info {
flex: 1;
}
.nickname {
font-size: 36rpx;
font-weight: 700;
color: #ffffff;
display: block;
margin-bottom: 8rpx;
}
.user-bio {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.75);
display: block;
margin-bottom: 8rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 400rpx;
}
.user-meta {
display: flex;
align-items: center;
gap: 16rpx;
}
.meta-item {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.8);
}
.identity-badge {
background: rgba(255, 255, 255, 0.2);
padding: 4rpx 16rpx;
border-radius: 8rpx;
}
.identity-text {
font-size: 22rpx;
color: #ffffff;
}
.login-card {
background: #ffffff;
border-radius: 24rpx;
padding: 60rpx 40rpx;
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 32rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
}
.login-avatar {
width: 120rpx;
height: 120rpx;
border-radius: 60rpx;
background: #e0e7ff;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20rpx;
}
.login-avatar-icon {
font-size: 56rpx;
}
.login-hint {
font-size: 30rpx;
color: #64748b;
margin-bottom: 32rpx;
}
.login-btn {
width: 320rpx;
height: 80rpx;
line-height: 80rpx;
background: #6366f1;
color: #ffffff;
font-size: 30rpx;
font-weight: 600;
border-radius: 40rpx;
border: none;
}
.login-btn::after {
border: none;
}
.stats-card {
display: flex;
background: #ffffff;
border-radius: 16rpx;
padding: 28rpx 0;
margin-bottom: 24rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
}
.stat-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
}
.stat-num {
font-size: 36rpx;
font-weight: 700;
color: #1e293b;
}
.stat-label {
font-size: 22rpx;
color: #94a3b8;
}
.menu-right {
display: flex;
align-items: center;
gap: 8rpx;
}
.badge {
background: #ef4444;
min-width: 32rpx;
height: 32rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
padding: 0 8rpx;
}
.badge-text {
font-size: 20rpx;
color: #fff;
font-weight: 600;
}
.menu-card {
background: #ffffff;
border-radius: 16rpx;
overflow: hidden;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
margin-bottom: 32rpx;
}
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx;
border-bottom: 1rpx solid #f1f5f9;
}
.menu-item:last-child {
border-bottom: none;
}
.menu-left {
display: flex;
align-items: center;
}
.menu-icon {
font-size: 36rpx;
margin-right: 20rpx;
}
.menu-label {
font-size: 30rpx;
color: #1e293b;
}
.menu-arrow {
font-size: 36rpx;
color: #cbd5e1;
}
.logout-area {
padding: 16rpx 0 48rpx;
}
.logout-btn {
width: 100%;
height: 84rpx;
line-height: 84rpx;
background: #ffffff;
color: #ef4444;
font-size: 30rpx;
border-radius: 16rpx;
border: none;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
}
.logout-btn::after {
border: none;
}
</style>
+260
View File
@@ -0,0 +1,260 @@
<script setup>
import { ref, onMounted } from "vue";
import { getMembershipPlans, getMyMembership, purchaseMembership } from "@/api/membership";
const plans = ref([]);
const myMembership = ref(null);
const loading = ref(true);
async function loadData() {
loading.value = true;
try {
const [plansRes, myRes] = await Promise.all([
getMembershipPlans(),
getMyMembership(),
]);
plans.value = Array.isArray(plansRes) ? plansRes : [];
myMembership.value = myRes || null;
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
}
async function handlePurchase(planId) {
uni.showModal({
title: "确认开通",
content: "确认购买该会员方案?",
success: async (r) => {
if (!r.confirm) return;
try {
const res = await purchaseMembership(planId);
myMembership.value = res;
uni.showToast({ title: "开通成功", icon: "success" });
} catch (e) {
uni.showToast({ title: e.message || "购买失败", icon: "none" });
}
},
});
}
function formatDate(d) {
if (!d) return "";
const dt = new Date(d);
return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, "0")}-${String(dt.getDate()).padStart(2, "0")}`;
}
function daysLeft(endDate) {
if (!endDate) return 0;
const diff = new Date(endDate).getTime() - Date.now();
return Math.max(0, Math.ceil(diff / (1000 * 60 * 60 * 24)));
}
onMounted(loadData);
</script>
<template>
<view class="membership-page">
<view v-if="loading" class="loading-tip">加载中...</view>
<template v-else>
<!-- Current membership -->
<view v-if="myMembership" class="current-card">
<view class="current-header">
<uni-icons type="star-filled" size="24" color="#f59e0b" />
<text class="current-title">{{ myMembership.plan?.name || '会员' }}</text>
</view>
<view class="current-info">
<view class="current-row">
<text class="current-label">到期时间</text>
<text class="current-value">{{ formatDate(myMembership.end_date) }}</text>
</view>
<view class="current-row">
<text class="current-label">剩余天数</text>
<text class="current-value highlight">{{ daysLeft(myMembership.end_date) }}</text>
</view>
</view>
</view>
<view v-else class="no-member-card">
<uni-icons type="star" size="32" color="#d1d5db" />
<text class="no-member-text">您还不是会员</text>
</view>
<!-- Plans -->
<view class="section-title">会员方案</view>
<view v-if="plans.length === 0" class="empty-tip">暂无可用方案</view>
<view v-for="plan in plans" :key="plan.id" class="plan-card">
<view class="plan-header">
<text class="plan-name">{{ plan.name }}</text>
<text class="plan-price">¥{{ plan.price }}</text>
</view>
<text v-if="plan.description" class="plan-desc">{{ plan.description }}</text>
<view class="plan-meta">
<text class="plan-duration">{{ plan.duration_days }}</text>
<text v-if="plan.extra_uploads" class="plan-benefit">+{{ plan.extra_uploads }}上传额度</text>
<text v-if="plan.extra_top_count" class="plan-benefit">+{{ plan.extra_top_count }}置顶次数</text>
</view>
<view v-if="plan.benefits" class="plan-benefits">
<text class="benefits-text">{{ plan.benefits }}</text>
</view>
<view class="plan-action">
<view class="purchase-btn" @tap="handlePurchase(plan.id)">
{{ myMembership ? '续费' : '立即开通' }}
</view>
</view>
</view>
</template>
</view>
</template>
<style scoped>
.membership-page {
min-height: 100vh;
background: #f5f6fa;
padding: 20rpx;
}
.loading-tip {
text-align: center;
padding: 100rpx 0;
font-size: 28rpx;
color: #9ca3af;
}
.current-card {
background: linear-gradient(135deg, #f59e0b, #fbbf24);
border-radius: 20rpx;
padding: 32rpx;
margin-bottom: 24rpx;
}
.current-header {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 16rpx;
}
.current-title {
font-size: 32rpx;
font-weight: 700;
color: #fff;
}
.current-info {
display: flex;
gap: 40rpx;
}
.current-row {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.current-label {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.8);
}
.current-value {
font-size: 26rpx;
color: #fff;
font-weight: 600;
}
.current-value.highlight {
font-size: 32rpx;
}
.no-member-card {
background: #fff;
border-radius: 20rpx;
padding: 48rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
margin-bottom: 24rpx;
}
.no-member-text {
font-size: 28rpx;
color: #9ca3af;
}
.section-title {
font-size: 30rpx;
font-weight: 700;
color: #1e1e2e;
margin-bottom: 16rpx;
padding-left: 8rpx;
}
.empty-tip {
text-align: center;
font-size: 26rpx;
color: #9ca3af;
padding: 40rpx 0;
}
.plan-card {
background: #fff;
border-radius: 20rpx;
padding: 28rpx;
margin-bottom: 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
}
.plan-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
}
.plan-name {
font-size: 30rpx;
font-weight: 700;
color: #1e1e2e;
}
.plan-price {
font-size: 32rpx;
font-weight: 700;
color: #ef4444;
}
.plan-desc {
font-size: 24rpx;
color: #6b7280;
margin-bottom: 12rpx;
}
.plan-meta {
display: flex;
gap: 16rpx;
flex-wrap: wrap;
margin-bottom: 12rpx;
}
.plan-duration {
font-size: 24rpx;
color: #6366f1;
background: #eef2ff;
padding: 4rpx 16rpx;
border-radius: 16rpx;
}
.plan-benefit {
font-size: 24rpx;
color: #d97706;
background: #fef3c7;
padding: 4rpx 16rpx;
border-radius: 16rpx;
}
.plan-benefits {
margin-bottom: 16rpx;
}
.benefits-text {
font-size: 24rpx;
color: #4b5563;
line-height: 1.6;
white-space: pre-wrap;
}
.plan-action {
display: flex;
justify-content: flex-end;
}
.purchase-btn {
padding: 14rpx 40rpx;
background: #6366f1;
color: #fff;
font-size: 26rpx;
font-weight: 600;
border-radius: 32rpx;
}
</style>
+355
View File
@@ -0,0 +1,355 @@
<script setup>
import { ref } from "vue";
import { onPullDownRefresh, onReachBottom, onShow } from "@dcloudio/uni-app";
import { getMySpots, deleteSpot } from "@/api/spot";
import { extractList } from "@/utils/request";
import { resolveImageUrl } from "@/utils/image";
import { checkLogin } from "@/utils/auth";
import { formatSpotPrice } from "@/utils/spot";
onShow(() => {
if (checkLogin()) fetchSpots(true);
});
const spots = ref([]);
const page = ref(1);
const pageSize = 10;
const hasMore = ref(true);
const loading = ref(false);
const statusMap = {
pending: { text: "待审核", color: "#f59e0b", bg: "rgba(245,158,11,0.1)" },
approved: { text: "已通过", color: "#22c55e", bg: "rgba(34,197,94,0.1)" },
rejected: { text: "已拒绝", color: "#ef4444", bg: "rgba(239,68,68,0.1)" },
};
const getStatus = (s) => statusMap[s] || { text: s, color: "#94a3b8", bg: "#f5f6fa" };
const fetchSpots = async (reset = false) => {
if (loading.value) return;
if (!reset && !hasMore.value) return;
loading.value = true;
if (reset) {
page.value = 1;
hasMore.value = true;
}
try {
const res = await getMySpots({ page: page.value, page_size: pageSize });
const list = extractList(res);
if (reset) {
spots.value = list;
} else {
spots.value.push(...list);
}
if (list.length < pageSize) {
hasMore.value = false;
} else {
page.value++;
}
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
};
const goDetail = (id) => {
uni.navigateTo({ url: `/pages/spot/detail?id=${id}` });
};
const goEdit = (id) => {
uni.navigateTo({ url: `/pages/spot/edit?id=${id}` });
};
const handleDelete = (item) => {
uni.showModal({
title: "确认删除",
content: `确定要删除「${item.title}」吗?此操作不可撤销。`,
confirmColor: "#ef4444",
success: async (res) => {
if (res.confirm) {
try {
await deleteSpot(item.id);
uni.showToast({ title: "已删除", icon: "success" });
spots.value = spots.value.filter((s) => s.id !== item.id);
} catch (e) {
console.error(e);
}
}
},
});
};
onPullDownRefresh(async () => {
await fetchSpots(true);
uni.stopPullDownRefresh();
});
onReachBottom(() => {
fetchSpots();
});
fetchSpots(true);
</script>
<template>
<view class="my-spots-page">
<view class="list">
<view
v-for="item in spots"
:key="item.id"
class="spot-card"
@tap="goDetail(item.id)"
>
<image
v-if="item.cover_image_url"
class="cover"
:src="resolveImageUrl(item.cover_image_url)"
mode="aspectFill"
/>
<view v-else class="cover cover-placeholder">
<uni-icons type="camera" size="32" color="#94a3b8" class="placeholder-icon" />
</view>
<view class="info">
<view class="title-row">
<text class="title">{{ item.title }}</text>
<view
class="status-badge"
:style="{ background: getStatus(item.audit_status).bg }"
>
<text
class="status-text"
:style="{ color: getStatus(item.audit_status).color }"
>
{{ getStatus(item.audit_status).text }}
</text>
</view>
</view>
<view class="meta-row">
<view class="city"><uni-icons type="location" size="14" color="#64748b" /> {{ item.city || "未知城市" }}</view>
<text
class="price-text"
:class="{ free: formatSpotPrice(item).isFree, paid: !formatSpotPrice(item).isFree }"
>
{{ formatSpotPrice(item).label }}
</text>
</view>
<view
v-if="item.audit_status === 'rejected' && item.reject_reason"
class="reject-row"
>
<text class="reject-label">拒绝原因</text>
<text class="reject-reason">{{ item.reject_reason }}</text>
</view>
<view class="action-row">
<view class="action-btn edit-btn" @tap.stop="goEdit(item.id)">
<uni-icons type="compose" size="16" color="#6366f1" />
<text class="action-text edit-text">编辑</text>
</view>
<view class="action-btn delete-btn" @tap.stop="handleDelete(item)">
<uni-icons type="trash" size="16" color="#ef4444" />
<text class="action-text delete-text">删除</text>
</view>
</view>
</view>
</view>
<view v-if="loading" class="status-tip">
<text>加载中...</text>
</view>
<view v-else-if="!hasMore && spots.length > 0" class="status-tip">
<text>没有更多了</text>
</view>
<view v-else-if="!loading && spots.length === 0" class="empty-state">
<uni-icons type="location" size="48" color="#6366f1" class="empty-icon" />
<text class="empty-title">还没有投稿地点</text>
<text class="empty-desc">去投稿你发现的取景地吧</text>
</view>
</view>
</view>
</template>
<style scoped>
.my-spots-page {
min-height: 100vh;
background: #f5f6fa;
}
.list {
padding: 24rpx 32rpx;
}
.spot-card {
background: #ffffff;
border-radius: 16rpx;
overflow: hidden;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
margin-bottom: 24rpx;
}
.cover {
width: 100%;
height: 280rpx;
}
.cover-placeholder {
background: #e2e8f0;
display: flex;
align-items: center;
justify-content: center;
}
.placeholder-icon {
font-size: 64rpx;
}
.info {
padding: 20rpx 24rpx 24rpx;
}
.title-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
}
.title {
font-size: 30rpx;
font-weight: 600;
color: #1e293b;
flex: 1;
margin-right: 16rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-badge {
padding: 6rpx 16rpx;
border-radius: 8rpx;
flex-shrink: 0;
}
.status-text {
font-size: 22rpx;
font-weight: 600;
}
.meta-row {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 8rpx;
flex-wrap: wrap;
}
.city {
font-size: 24rpx;
color: #64748b;
}
.price-text {
font-size: 24rpx;
font-weight: 600;
}
.price-text.free {
color: #16a34a;
}
.price-text.paid {
color: #d97706;
}
.reject-row {
margin-top: 12rpx;
background: rgba(239, 68, 68, 0.06);
padding: 16rpx 20rpx;
border-radius: 10rpx;
}
.reject-label {
font-size: 24rpx;
color: #ef4444;
font-weight: 500;
}
.reject-reason {
font-size: 24rpx;
color: #64748b;
line-height: 1.5;
}
.action-row {
display: flex;
gap: 16rpx;
margin-top: 16rpx;
padding-top: 16rpx;
border-top: 1rpx solid #f1f5f9;
}
.action-btn {
display: flex;
align-items: center;
gap: 6rpx;
padding: 10rpx 24rpx;
border-radius: 8rpx;
}
.edit-btn {
background: rgba(99, 102, 241, 0.08);
}
.delete-btn {
background: rgba(239, 68, 68, 0.08);
}
.action-text {
font-size: 24rpx;
font-weight: 500;
}
.edit-text {
color: #6366f1;
}
.delete-text {
color: #ef4444;
}
.status-tip {
text-align: center;
padding: 40rpx 0;
color: #94a3b8;
font-size: 26rpx;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 160rpx 0;
}
.empty-icon {
font-size: 96rpx;
margin-bottom: 24rpx;
}
.empty-title {
font-size: 32rpx;
font-weight: 600;
color: #1e293b;
margin-bottom: 12rpx;
}
.empty-desc {
font-size: 26rpx;
color: #94a3b8;
}
</style>
+196
View File
@@ -0,0 +1,196 @@
<script setup>
import { ref } from "vue";
import { onPullDownRefresh, onReachBottom, onShow } from "@dcloudio/uni-app";
import { getNotifications, markAllRead, markRead } from "@/api/notification";
import { extractList } from "@/utils/request";
import { checkLogin } from "@/utils/auth";
onShow(() => { checkLogin(); });
const items = ref([]);
const page = ref(1);
const pageSize = 20;
const hasMore = ref(true);
const loading = ref(false);
const typeIcon = {
audit: "checkbox-filled",
comment: "chat",
system: "info",
};
const typeColor = {
audit: "#6366f1",
comment: "#3b82f6",
system: "#94a3b8",
};
const fetchList = async (reset = false) => {
if (loading.value) return;
if (!reset && !hasMore.value) return;
loading.value = true;
if (reset) { page.value = 1; hasMore.value = true; }
try {
const res = await getNotifications({ page: page.value, page_size: pageSize });
const list = extractList(res);
if (reset) items.value = list; else items.value.push(...list);
if (list.length < pageSize) hasMore.value = false; else page.value++;
} catch (e) { console.error(e); }
finally { loading.value = false; }
};
const handleTap = async (item) => {
if (!item.is_read) {
try { await markRead(item.id); item.is_read = true; } catch (e) { /* */ }
}
if (item.ref_type === "spot" && item.ref_id) {
uni.navigateTo({ url: `/pages/spot/detail?id=${item.ref_id}` });
}
};
const handleReadAll = async () => {
try {
await markAllRead();
items.value.forEach((n) => (n.is_read = true));
uni.showToast({ title: "已全部标记已读", icon: "success" });
} catch (e) { console.error(e); }
};
onPullDownRefresh(async () => { await fetchList(true); uni.stopPullDownRefresh(); });
onReachBottom(() => { fetchList(); });
fetchList(true);
</script>
<template>
<view class="notification-page">
<view class="header-bar">
<text class="header-title">消息通知</text>
<text class="read-all" @tap="handleReadAll">全部已读</text>
</view>
<view class="list">
<view
v-for="item in items"
:key="item.id"
class="noti-item"
:class="{ unread: !item.is_read }"
@tap="handleTap(item)"
>
<view class="noti-icon" :style="{ background: (typeColor[item.type] || '#94a3b8') + '18' }">
<uni-icons :type="typeIcon[item.type] || 'info'" size="20" :color="typeColor[item.type] || '#94a3b8'" />
</view>
<view class="noti-body">
<text class="noti-title">{{ item.title }}</text>
<text v-if="item.content" class="noti-content">{{ item.content }}</text>
<text class="noti-time">{{ item.created_at?.slice(0, 16).replace('T', ' ') }}</text>
</view>
<view v-if="!item.is_read" class="noti-dot" />
</view>
</view>
<view v-if="loading" class="status-tip"><text>加载中...</text></view>
<view v-else-if="!hasMore && items.length > 0" class="status-tip"><text>没有更多了</text></view>
<view v-else-if="!loading && items.length === 0" class="empty-state">
<uni-icons type="chat" size="48" color="#cbd5e1" />
<text class="empty-text">暂无消息</text>
</view>
</view>
</template>
<style scoped>
.notification-page {
min-height: 100vh;
background: #f5f6fa;
}
.header-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 32rpx;
}
.header-title {
font-size: 32rpx;
font-weight: 700;
color: #1e293b;
}
.read-all {
font-size: 26rpx;
color: #6366f1;
}
.list {
padding: 0 32rpx;
}
.noti-item {
display: flex;
align-items: flex-start;
gap: 20rpx;
padding: 24rpx;
background: #fff;
border-radius: 16rpx;
margin-bottom: 16rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
position: relative;
}
.noti-item.unread {
background: #f0f0ff;
}
.noti-icon {
width: 72rpx;
height: 72rpx;
border-radius: 36rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.noti-body {
flex: 1;
min-width: 0;
}
.noti-title {
font-size: 28rpx;
font-weight: 600;
color: #1e293b;
display: block;
margin-bottom: 6rpx;
}
.noti-content {
font-size: 24rpx;
color: #64748b;
display: block;
margin-bottom: 8rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.noti-time {
font-size: 22rpx;
color: #94a3b8;
}
.noti-dot {
width: 16rpx;
height: 16rpx;
background: #ef4444;
border-radius: 8rpx;
position: absolute;
top: 28rpx;
right: 24rpx;
}
.status-tip {
text-align: center;
padding: 40rpx 0;
color: #94a3b8;
font-size: 26rpx;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 160rpx 0;
}
.empty-text {
margin-top: 16rpx;
font-size: 28rpx;
color: #94a3b8;
}
</style>
+247
View File
@@ -0,0 +1,247 @@
<script setup>
import { ref } from "vue";
import { onPullDownRefresh, onReachBottom, onShow } from "@dcloudio/uni-app";
import { getMyPoints, getMyPointRecords } from "@/api/point";
import { checkLogin } from "@/utils/auth";
onShow(() => { checkLogin(); });
const balance = ref(0);
const records = ref([]);
const page = ref(1);
const pageSize = 15;
const hasMore = ref(true);
const loading = ref(false);
const fetchBalance = async () => {
try {
const res = await getMyPoints();
balance.value = res.balance ?? res.points ?? 0;
} catch (e) {
console.error(e);
}
};
const fetchRecords = async (reset = false) => {
if (loading.value) return;
if (!reset && !hasMore.value) return;
loading.value = true;
if (reset) {
page.value = 1;
hasMore.value = true;
}
try {
const res = await getMyPointRecords({ page: page.value, page_size: pageSize });
const list = res.items || res.data || res || [];
if (reset) {
records.value = list;
} else {
records.value.push(...list);
}
if (list.length < pageSize) {
hasMore.value = false;
} else {
page.value++;
}
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
};
const formatDate = (dateStr) => {
if (!dateStr) return "";
const d = new Date(dateStr);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
const h = String(d.getHours()).padStart(2, "0");
const min = String(d.getMinutes()).padStart(2, "0");
return `${y}-${m}-${day} ${h}:${min}`;
};
const formatChange = (val) => {
return val > 0 ? `+${val}` : `${val}`;
};
onPullDownRefresh(async () => {
await Promise.all([fetchBalance(), fetchRecords(true)]);
uni.stopPullDownRefresh();
});
onReachBottom(() => {
fetchRecords();
});
fetchBalance();
fetchRecords(true);
</script>
<template>
<view class="points-page">
<view class="balance-card">
<text class="balance-label">当前积分</text>
<text class="balance-value">{{ balance }}</text>
</view>
<view class="section-header">
<text class="section-title">积分记录</text>
</view>
<view class="records-list">
<view
v-for="item in records"
:key="item.id"
class="record-item"
>
<view class="record-left">
<text class="record-reason">{{ item.reason || "积分变动" }}</text>
<text class="record-date">{{ formatDate(item.created_at) }}</text>
</view>
<text
class="record-change"
:class="item.change > 0 ? 'positive' : 'negative'"
>
{{ formatChange(item.change) }}
</text>
</view>
<view v-if="loading" class="status-tip">
<text>加载中...</text>
</view>
<view v-else-if="!hasMore && records.length > 0" class="status-tip">
<text>没有更多了</text>
</view>
<view v-else-if="!loading && records.length === 0" class="empty-state">
<uni-icons type="star-filled" size="48" color="#6366f1" class="empty-icon" />
<text class="empty-title">暂无积分记录</text>
<text class="empty-desc">投稿取景地获得收藏可以赚取积分</text>
</view>
</view>
</view>
</template>
<style scoped>
.points-page {
min-height: 100vh;
background: #f5f6fa;
}
.balance-card {
margin: 24rpx 32rpx;
background: linear-gradient(135deg, #6366f1, #818cf8);
border-radius: 24rpx;
padding: 48rpx 40rpx;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 8rpx 32rpx rgba(99, 102, 241, 0.25);
}
.balance-label {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.8);
margin-bottom: 16rpx;
}
.balance-value {
font-size: 80rpx;
font-weight: 800;
color: #ffffff;
line-height: 1;
}
.section-header {
padding: 24rpx 32rpx 16rpx;
}
.section-title {
font-size: 30rpx;
font-weight: 700;
color: #1e293b;
}
.records-list {
padding: 0 32rpx 32rpx;
}
.record-item {
display: flex;
align-items: center;
justify-content: space-between;
background: #ffffff;
padding: 28rpx 28rpx;
border-radius: 14rpx;
margin-bottom: 16rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.03);
}
.record-left {
flex: 1;
margin-right: 20rpx;
}
.record-reason {
font-size: 28rpx;
color: #1e293b;
font-weight: 500;
display: block;
margin-bottom: 6rpx;
}
.record-date {
font-size: 24rpx;
color: #94a3b8;
display: block;
}
.record-change {
font-size: 34rpx;
font-weight: 700;
flex-shrink: 0;
}
.record-change.positive {
color: #22c55e;
}
.record-change.negative {
color: #ef4444;
}
.status-tip {
text-align: center;
padding: 40rpx 0;
color: #94a3b8;
font-size: 26rpx;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 120rpx 0;
}
.empty-icon {
font-size: 96rpx;
margin-bottom: 24rpx;
}
.empty-title {
font-size: 32rpx;
font-weight: 600;
color: #1e293b;
margin-bottom: 12rpx;
}
.empty-desc {
font-size: 26rpx;
color: #94a3b8;
}
</style>
+350
View File
@@ -0,0 +1,350 @@
<script setup>
import { ref, onMounted } from "vue";
import { onShow } from "@dcloudio/uni-app";
import { useUserStore } from "@/store/user";
import { updateMyInfo } from "@/api/user";
import { uploadImage } from "@/api/spot";
import { resolveImageUrl } from "@/utils/image";
import { checkLogin } from "@/utils/auth";
import cityData from "@/utils/city-data";
const userStore = useUserStore();
const loading = ref(false);
const form = ref({
nickname: "",
avatar_url: "",
city: "",
bio: "",
identity: "both",
});
const identityOptions = [
{ label: "摄影师", value: "photographer" },
{ label: "Coser", value: "cosplayer" },
{ label: "都是", value: "both" },
];
const provinces = cityData.map((p) => p.province);
const cityColumns = ref([provinces, cityData[0].cities]);
const cityPickerIndex = ref([0, 0]);
const initForm = () => {
const u = userStore.userInfo;
if (!u) return;
form.value.nickname = u.nickname || "";
form.value.avatar_url = u.avatar_url || "";
form.value.bio = u.bio || "";
form.value.identity = u.identity || "both";
form.value.city = u.city || "";
if (u.city) {
const parts = u.city.split(" ");
if (parts.length === 2) {
const pi = provinces.indexOf(parts[0]);
if (pi >= 0) {
const ci = cityData[pi].cities.indexOf(parts[1]);
cityPickerIndex.value = [pi, ci >= 0 ? ci : 0];
cityColumns.value = [provinces, cityData[pi].cities];
}
}
}
};
onShow(() => {
if (!checkLogin()) return;
});
onMounted(async () => {
await userStore.fetchUserInfo();
initForm();
});
const onCityColumnChange = (e) => {
const { column, value } = e.detail;
if (column === 0) {
cityColumns.value = [provinces, cityData[value].cities];
cityPickerIndex.value = [value, 0];
} else {
cityPickerIndex.value = [cityPickerIndex.value[0], value];
}
};
const onCityChange = (e) => {
const [pi, ci] = e.detail.value;
const province = provinces[pi];
const city = cityData[pi].cities[ci];
form.value.city = `${province} ${city}`;
};
const chooseAvatar = () => {
uni.chooseImage({
count: 1,
sizeType: ["compressed"],
success: async (res) => {
const tempPath = res.tempFilePaths[0];
try {
uni.showLoading({ title: "上传中..." });
const data = await uploadImage(tempPath);
form.value.avatar_url = data.url;
uni.hideLoading();
} catch (e) {
uni.hideLoading();
uni.showToast({ title: "头像上传失败", icon: "none" });
}
},
});
};
const handleSave = async () => {
if (!form.value.nickname.trim()) {
uni.showToast({ title: "昵称不能为空", icon: "none" });
return;
}
loading.value = true;
try {
await updateMyInfo({
nickname: form.value.nickname.trim(),
avatar_url: form.value.avatar_url || null,
city: form.value.city || null,
bio: form.value.bio.trim() || null,
identity: form.value.identity,
});
await userStore.fetchUserInfo();
uni.showToast({ title: "保存成功", icon: "success" });
setTimeout(() => uni.navigateBack(), 800);
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
};
</script>
<template>
<view class="profile-page">
<view class="avatar-section" @tap="chooseAvatar">
<image
v-if="form.avatar_url"
class="avatar-img"
:src="resolveImageUrl(form.avatar_url)"
mode="aspectFill"
/>
<view v-else class="avatar-img avatar-placeholder">
<uni-icons type="camera-filled" size="32" color="#94a3b8" />
</view>
<text class="avatar-tip">点击更换头像</text>
</view>
<view class="form-card">
<view class="field">
<text class="label">昵称</text>
<input
v-model="form.nickname"
class="input"
maxlength="20"
placeholder="请输入昵称"
placeholder-class="placeholder"
/>
</view>
<view class="field">
<text class="label">个人简介</text>
<textarea
v-model="form.bio"
class="textarea"
maxlength="120"
placeholder="一句话介绍自己"
placeholder-class="placeholder"
:auto-height="false"
/>
</view>
<view class="field">
<text class="label">所在城市</text>
<picker
mode="multiSelector"
:value="cityPickerIndex"
:range="cityColumns"
@columnchange="onCityColumnChange"
@change="onCityChange"
>
<view class="picker-value">
<text :class="form.city ? '' : 'placeholder'">{{ form.city || '请选择城市' }}</text>
<uni-icons type="right" size="16" color="#cbd5e1" />
</view>
</picker>
</view>
<view class="field">
<text class="label">身份</text>
<view class="identity-toggle">
<view
v-for="opt in identityOptions"
:key="opt.value"
class="toggle-btn"
:class="{ active: form.identity === opt.value }"
@tap="form.identity = opt.value"
>
<text class="toggle-text">{{ opt.label }}</text>
</view>
</view>
</view>
</view>
<button class="save-btn" :loading="loading" :disabled="loading" @tap="handleSave">
保存
</button>
</view>
</template>
<style scoped>
.profile-page {
min-height: 100vh;
background: #f5f6fa;
padding: 32rpx;
}
.avatar-section {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 32rpx;
}
.avatar-img {
width: 160rpx;
height: 160rpx;
border-radius: 80rpx;
border: 4rpx solid #e2e8f0;
}
.avatar-placeholder {
background: #f1f5f9;
display: flex;
align-items: center;
justify-content: center;
}
.avatar-tip {
font-size: 24rpx;
color: #6366f1;
margin-top: 12rpx;
}
.form-card {
background: #ffffff;
border-radius: 16rpx;
padding: 24rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
margin-bottom: 40rpx;
}
.field {
margin-bottom: 28rpx;
}
.field:last-child {
margin-bottom: 0;
}
.label {
display: block;
font-size: 26rpx;
color: #64748b;
margin-bottom: 12rpx;
font-weight: 500;
}
.input {
width: 100%;
height: 80rpx;
background: #f8fafc;
border: 2rpx solid #e2e8f0;
border-radius: 12rpx;
padding: 0 24rpx;
font-size: 28rpx;
color: #1e293b;
box-sizing: border-box;
}
.textarea {
width: 100%;
height: 160rpx;
background: #f8fafc;
border: 2rpx solid #e2e8f0;
border-radius: 12rpx;
padding: 20rpx 24rpx;
font-size: 28rpx;
color: #1e293b;
box-sizing: border-box;
}
.placeholder {
color: #94a3b8;
}
.picker-value {
display: flex;
align-items: center;
justify-content: space-between;
height: 80rpx;
background: #f8fafc;
border: 2rpx solid #e2e8f0;
border-radius: 12rpx;
padding: 0 24rpx;
font-size: 28rpx;
color: #1e293b;
}
.identity-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;
}
.save-btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
background: #6366f1;
color: #ffffff;
font-size: 32rpx;
font-weight: 600;
border-radius: 16rpx;
border: none;
}
.save-btn::after {
border: none;
}
.save-btn[disabled] {
opacity: 0.6;
}
</style>
+231
View File
@@ -0,0 +1,231 @@
<script setup>
import { ref } from "vue";
import { onShow } from "@dcloudio/uni-app";
import { checkLogin } from "@/utils/auth";
import { changePassword } from "@/api/user";
import { useUserStore } from "@/store/user";
onShow(() => { checkLogin(); });
const userStore = useUserStore();
const showPwdForm = ref(false);
const pwdForm = ref({ old_password: "", new_password: "", confirm: "" });
const saving = ref(false);
const handleChangePwd = async () => {
if (!pwdForm.value.old_password || !pwdForm.value.new_password) {
uni.showToast({ title: "请填写完整", icon: "none" });
return;
}
if (pwdForm.value.new_password.length < 6) {
uni.showToast({ title: "新密码至少6位", icon: "none" });
return;
}
if (pwdForm.value.new_password !== pwdForm.value.confirm) {
uni.showToast({ title: "两次密码不一致", icon: "none" });
return;
}
saving.value = true;
try {
await changePassword({
old_password: pwdForm.value.old_password,
new_password: pwdForm.value.new_password,
});
uni.showToast({ title: "密码修改成功", icon: "success" });
showPwdForm.value = false;
pwdForm.value = { old_password: "", new_password: "", confirm: "" };
} catch (e) {
console.error(e);
} finally {
saving.value = false;
}
};
const clearCache = () => {
uni.showModal({
title: "清除缓存",
content: "将清除本地缓存数据(不会影响账号数据),确认继续?",
success: (res) => {
if (res.confirm) {
const token = uni.getStorageSync("access_token");
const rt = uni.getStorageSync("refresh_token");
uni.clearStorageSync();
if (token) uni.setStorageSync("access_token", token);
if (rt) uni.setStorageSync("refresh_token", rt);
uni.showToast({ title: "缓存已清除", icon: "success" });
}
},
});
};
const handleLogout = () => {
uni.showModal({
title: "提示",
content: "确定要退出登录吗?",
success: (res) => {
if (res.confirm) userStore.logout();
},
});
};
</script>
<template>
<view class="settings-page">
<view class="menu-card">
<view class="menu-item" @tap="showPwdForm = !showPwdForm">
<view class="menu-left">
<uni-icons type="locked" size="22" color="#6366f1" />
<text class="menu-label">修改密码</text>
</view>
<uni-icons :type="showPwdForm ? 'arrowup' : 'right'" size="16" color="#cbd5e1" />
</view>
<view v-if="showPwdForm" class="pwd-form">
<input
v-model="pwdForm.old_password"
class="pwd-input"
type="password"
placeholder="当前密码"
placeholder-class="placeholder"
/>
<input
v-model="pwdForm.new_password"
class="pwd-input"
type="password"
placeholder="新密码(至少6位)"
placeholder-class="placeholder"
/>
<input
v-model="pwdForm.confirm"
class="pwd-input"
type="password"
placeholder="确认新密码"
placeholder-class="placeholder"
/>
<button class="pwd-btn" :loading="saving" :disabled="saving" @tap="handleChangePwd">
确认修改
</button>
</view>
<view class="menu-item" @tap="clearCache">
<view class="menu-left">
<uni-icons type="trash" size="22" color="#f59e0b" />
<text class="menu-label">清除缓存</text>
</view>
<uni-icons type="right" size="16" color="#cbd5e1" />
</view>
</view>
<view class="about-card">
<text class="about-title">关于次元取景器</text>
<text class="about-desc">发现和分享二次元取景地的社区平台</text>
<text class="about-version">v1.0.0</text>
</view>
<view class="logout-area">
<button class="logout-btn" @tap="handleLogout">退出登录</button>
</view>
</view>
</template>
<style scoped>
.settings-page {
min-height: 100vh;
background: #f5f6fa;
padding: 24rpx 32rpx;
}
.menu-card {
background: #fff;
border-radius: 16rpx;
overflow: hidden;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
margin-bottom: 32rpx;
}
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx;
border-bottom: 1rpx solid #f1f5f9;
}
.menu-item:last-child { border-bottom: none; }
.menu-left {
display: flex;
align-items: center;
gap: 16rpx;
}
.menu-label {
font-size: 30rpx;
color: #1e293b;
}
.pwd-form {
padding: 20rpx 32rpx 28rpx;
background: #f8fafc;
}
.pwd-input {
width: 100%;
height: 80rpx;
background: #fff;
border: 2rpx solid #e2e8f0;
border-radius: 12rpx;
padding: 0 24rpx;
font-size: 28rpx;
color: #1e293b;
box-sizing: border-box;
margin-bottom: 16rpx;
}
.placeholder { color: #94a3b8; }
.pwd-btn {
width: 100%;
height: 80rpx;
line-height: 80rpx;
background: #6366f1;
color: #fff;
font-size: 28rpx;
font-weight: 600;
border-radius: 12rpx;
border: none;
margin-top: 8rpx;
}
.pwd-btn::after { border: none; }
.pwd-btn[disabled] { opacity: 0.6; }
.about-card {
background: #fff;
border-radius: 16rpx;
padding: 40rpx 32rpx;
text-align: center;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
margin-bottom: 32rpx;
}
.about-title {
display: block;
font-size: 32rpx;
font-weight: 700;
color: #1e293b;
margin-bottom: 12rpx;
}
.about-desc {
display: block;
font-size: 26rpx;
color: #64748b;
margin-bottom: 8rpx;
}
.about-version {
display: block;
font-size: 22rpx;
color: #94a3b8;
}
.logout-area { padding: 16rpx 0 48rpx; }
.logout-btn {
width: 100%;
height: 84rpx;
line-height: 84rpx;
background: #fff;
color: #ef4444;
font-size: 30rpx;
border-radius: 16rpx;
border: none;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
}
.logout-btn::after { border: none; }
</style>
+296
View File
@@ -0,0 +1,296 @@
<script setup>
import { ref } from "vue";
import { onReachBottom } from "@dcloudio/uni-app";
import { searchSpots } from "@/api/search";
import { getTags } from "@/api/tag";
import { extractList } from "@/utils/request";
import SpotCard from "@/components/spot-card/spot-card.vue";
const keyword = ref("");
const hotTags = ref([]);
const results = ref([]);
const searched = ref(false);
const loading = ref(false);
const page = ref(1);
const pageSize = 10;
const hasMore = ref(true);
const fetchHotTags = async () => {
try {
const res = await getTags({ sort: "hot" });
hotTags.value = extractList(res);
} catch (e) {
console.error(e);
}
};
const doSearch = async (reset = true) => {
const q = keyword.value.trim();
if (!q) return;
if (loading.value) return;
if (!reset && !hasMore.value) return;
loading.value = true;
if (reset) {
page.value = 1;
hasMore.value = true;
results.value = [];
}
searched.value = true;
try {
const res = await searchSpots({
q,
page: page.value,
page_size: pageSize,
});
const list = extractList(res);
if (reset) {
results.value = list;
} else {
results.value.push(...list);
}
if (list.length < pageSize) {
hasMore.value = false;
} else {
page.value++;
}
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
};
const onTagTap = (tag) => {
keyword.value = tag.name;
doSearch();
};
const goDetail = (id) => {
uni.navigateTo({ url: `/pages/spot/detail?id=${id}` });
};
const loadMore = () => {
doSearch(false);
};
onReachBottom(() => {
if (searched.value) loadMore();
});
fetchHotTags();
</script>
<template>
<view class="search-page">
<view class="search-bar">
<view class="search-input-wrap">
<uni-icons type="search" size="16" color="#94a3b8" class="search-icon" />
<input
class="search-input"
v-model="keyword"
placeholder="搜索取景地"
confirm-type="search"
@confirm="doSearch()"
/>
</view>
<view class="search-btn" @tap="doSearch()">
<text class="search-btn-text">搜索</text>
</view>
</view>
<view v-if="!searched" class="pre-search">
<view v-if="hotTags.length" class="hot-section">
<text class="hot-title">热门标签</text>
<view class="hot-tags">
<view
v-for="tag in hotTags"
:key="tag.id"
class="hot-tag"
@tap="onTagTap(tag)"
>
<text class="hot-tag-text">{{ tag.name }}</text>
</view>
</view>
</view>
<view class="empty-hint">
<uni-icons type="search" size="40" color="#94a3b8" class="empty-hint-icon" />
<text class="empty-hint-text">输入关键词搜索取景地</text>
</view>
</view>
<view v-else class="results">
<view v-if="results.length > 0" class="result-list">
<SpotCard
v-for="item in results"
:key="item.id"
:spot="item"
@click="goDetail(item.id)"
/>
<view v-if="hasMore" class="load-more" @tap="loadMore">
<text>{{ loading ? "加载中..." : "加载更多" }}</text>
</view>
<view v-else class="no-more">
<text>没有更多了</text>
</view>
</view>
<view v-else-if="!loading" class="no-result">
<uni-icons type="info" size="40" color="#94a3b8" class="no-result-icon" />
<text class="no-result-text">没有找到相关取景地</text>
</view>
<view v-if="loading && results.length === 0" class="loading-tip">
<text>搜索中...</text>
</view>
</view>
</view>
</template>
<style scoped>
.search-page {
min-height: 100vh;
background: #f5f6fa;
}
.search-bar {
display: flex;
align-items: center;
padding: 16rpx 24rpx;
background: #ffffff;
}
.search-input-wrap {
flex: 1;
display: flex;
align-items: center;
background: #f5f6fa;
border-radius: 36rpx;
padding: 0 24rpx;
height: 72rpx;
}
.search-icon {
font-size: 28rpx;
margin-right: 12rpx;
}
.search-input {
flex: 1;
font-size: 28rpx;
color: #1e293b;
}
.search-btn {
margin-left: 16rpx;
background: #6366f1;
border-radius: 36rpx;
padding: 0 32rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
}
.search-btn-text {
color: #ffffff;
font-size: 28rpx;
font-weight: 500;
}
.pre-search {
padding: 32rpx;
}
.hot-section {
margin-bottom: 48rpx;
}
.hot-title {
font-size: 30rpx;
font-weight: 700;
color: #1e293b;
display: block;
margin-bottom: 20rpx;
}
.hot-tags {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.hot-tag {
background: #ffffff;
padding: 14rpx 28rpx;
border-radius: 32rpx;
border: 2rpx solid #e2e8f0;
}
.hot-tag-text {
font-size: 26rpx;
color: #475569;
}
.empty-hint {
display: flex;
flex-direction: column;
align-items: center;
padding: 80rpx 0;
}
.empty-hint-icon {
font-size: 80rpx;
margin-bottom: 20rpx;
}
.empty-hint-text {
font-size: 28rpx;
color: #94a3b8;
}
.results {
padding: 24rpx 32rpx;
}
.result-list {
padding-bottom: 32rpx;
}
.load-more {
text-align: center;
padding: 24rpx 0;
color: #6366f1;
font-size: 26rpx;
}
.no-more {
text-align: center;
padding: 24rpx 0;
color: #94a3b8;
font-size: 26rpx;
}
.no-result {
display: flex;
flex-direction: column;
align-items: center;
padding: 120rpx 0;
}
.no-result-icon {
font-size: 80rpx;
margin-bottom: 20rpx;
}
.no-result-text {
font-size: 28rpx;
color: #94a3b8;
}
.loading-tip {
text-align: center;
padding: 60rpx 0;
color: #94a3b8;
font-size: 28rpx;
}
</style>
+331
View File
@@ -0,0 +1,331 @@
<script setup>
import { ref, computed } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { createShooting, updateShooting, getShootingDetail } from "@/api/shooting";
const isEdit = ref(false);
const editId = ref(0);
const submitting = ref(false);
const form = ref({
title: "",
city: "",
description: "",
style: "",
shoot_date: "",
is_free: false,
budget_min: "",
budget_max: "",
role_needed: "photographer",
max_applicants: 1,
contact_info: "",
});
const roleOptions = [
{ label: "摄影师", value: "photographer" },
{ label: "Coser", value: "cosplayer" },
{ label: "不限", value: "both" },
];
const roleIndex = computed({
get() {
return roleOptions.findIndex((o) => o.value === form.value.role_needed);
},
set(idx) {
form.value.role_needed = roleOptions[idx].value;
},
});
function onDateChange(e) {
form.value.shoot_date = e.detail.value;
}
function validate() {
if (!form.value.title.trim()) {
uni.showToast({ title: "请输入标题", icon: "none" });
return false;
}
if (!form.value.city.trim()) {
uni.showToast({ title: "请输入城市", icon: "none" });
return false;
}
if (!form.value.is_free) {
const min = Number(form.value.budget_min);
const max = Number(form.value.budget_max);
if (min && max && min > max) {
uni.showToast({ title: "最低预算不能高于最高预算", icon: "none" });
return false;
}
}
return true;
}
async function handleSubmit() {
if (!validate()) return;
submitting.value = true;
const data = {
title: form.value.title.trim(),
city: form.value.city.trim(),
description: form.value.description.trim() || null,
style: form.value.style.trim() || null,
is_free: form.value.is_free,
role_needed: form.value.role_needed,
max_applicants: Number(form.value.max_applicants) || 1,
contact_info: form.value.contact_info.trim() || null,
};
if (form.value.shoot_date) {
data.shoot_date = new Date(form.value.shoot_date).toISOString();
}
if (!form.value.is_free) {
data.budget_min = form.value.budget_min ? Number(form.value.budget_min) : null;
data.budget_max = form.value.budget_max ? Number(form.value.budget_max) : null;
}
try {
if (isEdit.value) {
await updateShooting(editId.value, data);
uni.showToast({ title: "更新成功", icon: "success" });
} else {
await createShooting(data);
uni.showToast({ title: "发布成功,等待审核", icon: "success" });
}
setTimeout(() => uni.navigateBack(), 1200);
} catch (e) {
uni.showToast({ title: e.message || "提交失败", icon: "none" });
} finally {
submitting.value = false;
}
}
async function loadForEdit(id) {
try {
const d = await getShootingDetail(id);
form.value.title = d.title || "";
form.value.city = d.city || "";
form.value.description = d.description || "";
form.value.style = d.style || "";
form.value.is_free = d.is_free || false;
form.value.budget_min = d.budget_min ? String(d.budget_min) : "";
form.value.budget_max = d.budget_max ? String(d.budget_max) : "";
form.value.role_needed = d.role_needed || "photographer";
form.value.max_applicants = d.max_applicants || 1;
form.value.contact_info = d.contact_info || "";
if (d.shoot_date) {
form.value.shoot_date = d.shoot_date.substring(0, 10);
}
} catch (e) {
uni.showToast({ title: "加载失败", icon: "none" });
}
}
onLoad((query) => {
if (query.id) {
isEdit.value = true;
editId.value = Number(query.id);
uni.setNavigationBarTitle({ title: "编辑约拍" });
loadForEdit(editId.value);
}
});
</script>
<template>
<view class="create-page">
<view class="form-card">
<view class="form-row">
<text class="form-label required">标题</text>
<input
v-model="form.title"
class="form-input"
placeholder="如:上海外滩约拍"
:maxlength="100"
/>
</view>
<view class="form-row">
<text class="form-label required">城市</text>
<input
v-model="form.city"
class="form-input"
placeholder="如:上海"
:maxlength="50"
/>
</view>
<view class="form-row">
<text class="form-label">风格</text>
<input
v-model="form.style"
class="form-input"
placeholder="如:古风、JK、日系清新"
:maxlength="50"
/>
</view>
<view class="form-row">
<text class="form-label">拍摄日期</text>
<picker mode="date" :value="form.shoot_date" @change="onDateChange">
<view class="form-input picker-display">
{{ form.shoot_date || "选择日期(可选)" }}
</view>
</picker>
</view>
<view class="form-row">
<text class="form-label">需要角色</text>
<picker
:range="roleOptions"
range-key="label"
:value="roleIndex"
@change="roleIndex = $event.detail.value"
>
<view class="form-input picker-display">
{{ roleOptions[roleIndex]?.label || "选择" }}
</view>
</picker>
</view>
<view class="form-row">
<text class="form-label">招募人数</text>
<input
v-model="form.max_applicants"
class="form-input"
type="number"
placeholder="默认1人"
/>
</view>
<view class="form-row switch-row">
<text class="form-label">互免约拍</text>
<switch :checked="form.is_free" @change="form.is_free = $event.detail.value" color="#6366f1" />
</view>
<template v-if="!form.is_free">
<view class="form-row">
<text class="form-label">预算下限</text>
<input
v-model="form.budget_min"
class="form-input"
type="digit"
placeholder="最低价格(选填)"
/>
</view>
<view class="form-row">
<text class="form-label">预算上限</text>
<input
v-model="form.budget_max"
class="form-input"
type="digit"
placeholder="最高价格(选填)"
/>
</view>
</template>
<view class="form-row">
<text class="form-label">联系方式</text>
<input
v-model="form.contact_info"
class="form-input"
placeholder="微信/QQ/手机号(仅对方可见)"
:maxlength="100"
/>
</view>
<view class="form-row">
<text class="form-label">详细描述</text>
<textarea
v-model="form.description"
class="form-textarea"
placeholder="描述拍摄需求、时间安排等"
:maxlength="2000"
/>
</view>
</view>
<view class="submit-row">
<view
class="submit-btn"
:class="{ disabled: submitting }"
@tap="handleSubmit"
>
{{ submitting ? "提交中..." : isEdit ? "保存修改" : "发布约拍" }}
</view>
</view>
</view>
</template>
<style scoped>
.create-page {
min-height: 100vh;
background: #f5f6fa;
padding-bottom: 40rpx;
}
.form-card {
background: #fff;
margin: 16rpx 20rpx;
border-radius: 20rpx;
padding: 12rpx 28rpx;
}
.form-row {
padding: 20rpx 0;
border-bottom: 1rpx solid #f3f4f6;
}
.form-row:last-child {
border-bottom: none;
}
.switch-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.form-label {
font-size: 26rpx;
color: #374151;
margin-bottom: 10rpx;
display: block;
}
.form-label.required::before {
content: "* ";
color: #ef4444;
}
.form-input {
width: 100%;
height: 72rpx;
border: 1rpx solid #e5e7eb;
border-radius: 12rpx;
padding: 0 20rpx;
font-size: 28rpx;
box-sizing: border-box;
line-height: 72rpx;
}
.picker-display {
display: flex;
align-items: center;
color: #374151;
}
.form-textarea {
width: 100%;
min-height: 200rpx;
border: 1rpx solid #e5e7eb;
border-radius: 12rpx;
padding: 16rpx 20rpx;
font-size: 28rpx;
box-sizing: border-box;
}
.submit-row {
padding: 20rpx 28rpx;
}
.submit-btn {
text-align: center;
padding: 24rpx 0;
background: #6366f1;
color: #fff;
font-size: 30rpx;
font-weight: 700;
border-radius: 16rpx;
}
.submit-btn.disabled {
opacity: 0.6;
}
</style>
+572
View File
@@ -0,0 +1,572 @@
<script setup>
import { ref, computed } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import {
getShootingDetail,
applyToShooting,
withdrawApplication,
closeShooting,
getApplications,
acceptApplication,
rejectApplication,
} from "@/api/shooting";
const detail = ref(null);
const loading = ref(true);
const requestId = ref(0);
const applyMsg = ref("");
const showApplyModal = ref(false);
const applications = ref([]);
const showApplications = ref(false);
const isOwner = ref(false);
const roleLabels = {
photographer: "摄影师",
cosplayer: "Coser",
both: "不限",
};
const statusLabels = {
open: "招募中",
matched: "已匹配",
closed: "已关闭",
};
const statusColors = {
open: "#22c55e",
matched: "#f59e0b",
closed: "#9ca3af",
};
const appStatusLabels = {
pending: "待处理",
accepted: "已接受",
rejected: "已拒绝",
};
async function loadDetail() {
loading.value = true;
try {
const res = await getShootingDetail(requestId.value);
detail.value = res;
const userStr = uni.getStorageSync("userInfo");
if (userStr) {
try {
const u = typeof userStr === "string" ? JSON.parse(userStr) : userStr;
isOwner.value = u.id === res.creator?.id;
} catch {}
}
} catch (e) {
uni.showToast({ title: "加载失败", icon: "none" });
} finally {
loading.value = false;
}
}
async function loadApplications() {
try {
const res = await getApplications(requestId.value);
applications.value = Array.isArray(res) ? res : res.items || [];
showApplications.value = true;
} catch (e) {
uni.showToast({ title: "无权查看报名列表", icon: "none" });
}
}
async function handleApply() {
try {
await applyToShooting(requestId.value, { message: applyMsg.value });
uni.showToast({ title: "报名成功", icon: "success" });
showApplyModal.value = false;
applyMsg.value = "";
await loadDetail();
} catch (e) {
uni.showToast({ title: e.message || "报名失败", icon: "none" });
}
}
async function handleWithdraw() {
uni.showModal({
title: "提示",
content: "确定撤回报名?",
success: async (r) => {
if (!r.confirm) return;
try {
await withdrawApplication(requestId.value);
uni.showToast({ title: "已撤回", icon: "success" });
await loadDetail();
} catch (e) {
uni.showToast({ title: e.message || "操作失败", icon: "none" });
}
},
});
}
async function handleClose() {
uni.showModal({
title: "提示",
content: "确定关闭这条约拍?关闭后将不再接受报名。",
success: async (r) => {
if (!r.confirm) return;
try {
await closeShooting(requestId.value);
uni.showToast({ title: "已关闭", icon: "success" });
await loadDetail();
} catch (e) {
uni.showToast({ title: e.message || "操作失败", icon: "none" });
}
},
});
}
async function handleAccept(appId) {
try {
await acceptApplication(requestId.value, appId);
uni.showToast({ title: "已接受", icon: "success" });
await loadApplications();
await loadDetail();
} catch (e) {
uni.showToast({ title: e.message || "操作失败", icon: "none" });
}
}
async function handleReject(appId) {
try {
await rejectApplication(requestId.value, appId);
uni.showToast({ title: "已拒绝", icon: "success" });
await loadApplications();
} catch (e) {
uni.showToast({ title: e.message || "操作失败", icon: "none" });
}
}
function formatDate(d) {
if (!d) return "时间待定";
const dt = new Date(d);
return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, "0")}-${String(dt.getDate()).padStart(2, "0")}`;
}
function formatBudget(item) {
if (!item) return "";
if (item.is_free) return "互免";
if (item.budget_min && item.budget_max) return `¥${item.budget_min} - ¥${item.budget_max}`;
if (item.budget_min) return `¥${item.budget_min}`;
if (item.budget_max) return `¥${item.budget_max}以内`;
return "面议";
}
const canApply = computed(() => {
if (!detail.value) return false;
return detail.value.status === "open" && !detail.value.has_applied && !isOwner.value;
});
onLoad((query) => {
requestId.value = Number(query.id);
loadDetail();
});
</script>
<template>
<view class="detail-page">
<view v-if="loading" class="loading-tip">加载中...</view>
<template v-else-if="detail">
<view class="header-card">
<view class="title-row">
<text class="title">{{ detail.title }}</text>
<view
class="status-tag"
:style="{ background: statusColors[detail.status] || '#9ca3af' }"
>
{{ statusLabels[detail.status] || detail.status }}
</view>
</view>
<view class="creator-row" v-if="detail.creator">
<text class="creator-nick">{{ detail.creator.nickname }}</text>
<text class="create-time">{{ formatDate(detail.created_at) }} 发布</text>
</view>
</view>
<view class="info-card">
<view class="info-row">
<text class="info-label">城市</text>
<text class="info-value">{{ detail.city }}</text>
</view>
<view class="info-row">
<text class="info-label">拍摄时间</text>
<text class="info-value">{{ formatDate(detail.shoot_date) }}</text>
</view>
<view class="info-row" v-if="detail.style">
<text class="info-label">风格</text>
<text class="info-value">{{ detail.style }}</text>
</view>
<view class="info-row">
<text class="info-label">需要角色</text>
<text class="info-value">{{ roleLabels[detail.role_needed] || detail.role_needed }}</text>
</view>
<view class="info-row">
<text class="info-label">预算</text>
<text class="info-value">{{ formatBudget(detail) }}</text>
</view>
<view class="info-row">
<text class="info-label">招募人数</text>
<text class="info-value">{{ detail.max_applicants }}</text>
</view>
<view class="info-row" v-if="detail.application_count !== undefined">
<text class="info-label">已报名</text>
<text class="info-value">{{ detail.application_count }}</text>
</view>
<view class="info-row" v-if="isOwner && detail.contact_info">
<text class="info-label">联系方式</text>
<text class="info-value">{{ detail.contact_info }}</text>
</view>
</view>
<view v-if="detail.description" class="desc-card">
<text class="desc-title">详细描述</text>
<text class="desc-text">{{ detail.description }}</text>
</view>
<view v-if="detail.reject_reason" class="reject-card">
<uni-icons type="info" size="16" color="#ef4444" />
<text class="reject-text">驳回原因{{ detail.reject_reason }}</text>
</view>
<!-- Applications panel for owner -->
<view v-if="isOwner" class="app-section">
<view class="app-header" @tap="loadApplications">
<text class="app-title">报名列表</text>
<uni-icons type="right" size="16" color="#6366f1" />
</view>
<view v-if="showApplications" class="app-list">
<view v-if="applications.length === 0" class="app-empty">暂无报名</view>
<view
v-for="app in applications"
:key="app.id"
class="app-item"
>
<view class="app-info">
<text class="app-name">{{ app.applicant?.nickname || "匿名" }}</text>
<text class="app-msg" v-if="app.message">{{ app.message }}</text>
<text class="app-status" :class="app.status">{{ appStatusLabels[app.status] || app.status }}</text>
</view>
<view v-if="app.status === 'pending'" class="app-actions">
<view class="action-btn accept" @tap="handleAccept(app.id)">接受</view>
<view class="action-btn reject" @tap="handleReject(app.id)">拒绝</view>
</view>
</view>
</view>
</view>
<!-- Bottom actions -->
<view class="bottom-bar">
<template v-if="isOwner">
<view
v-if="detail.status === 'open'"
class="btn btn-close"
@tap="handleClose"
>关闭约拍</view>
<view
class="btn btn-edit"
@tap="uni.navigateTo({ url: `/pages/shooting/create?id=${detail.id}` })"
v-if="detail.status !== 'closed'"
>编辑</view>
</template>
<template v-else>
<view v-if="canApply" class="btn btn-apply" @tap="showApplyModal = true">
我要报名
</view>
<view
v-else-if="detail.has_applied"
class="btn btn-withdraw"
@tap="handleWithdraw"
>撤回报名</view>
<view v-else class="btn btn-disabled">
{{ detail.status === 'closed' ? '已关闭' : '已报名' }}
</view>
</template>
</view>
<!-- Apply modal -->
<view v-if="showApplyModal" class="modal-mask" @tap="showApplyModal = false">
<view class="modal-content" @tap.stop>
<text class="modal-title">报名约拍</text>
<textarea
v-model="applyMsg"
class="modal-textarea"
placeholder="留言给发布者(选填)"
:maxlength="500"
/>
<view class="modal-actions">
<view class="modal-btn cancel" @tap="showApplyModal = false">取消</view>
<view class="modal-btn confirm" @tap="handleApply">确认报名</view>
</view>
</view>
</view>
</template>
</view>
</template>
<style scoped>
.detail-page {
min-height: 100vh;
background: #f5f6fa;
padding-bottom: 140rpx;
}
.loading-tip {
text-align: center;
padding: 100rpx 0;
font-size: 28rpx;
color: #9ca3af;
}
.header-card,
.info-card,
.desc-card,
.reject-card,
.app-section {
background: #fff;
margin: 16rpx 20rpx 0;
border-radius: 20rpx;
padding: 28rpx;
}
.title-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.title {
font-size: 34rpx;
font-weight: 700;
color: #1e1e2e;
flex: 1;
}
.status-tag {
font-size: 22rpx;
color: #fff;
padding: 4rpx 16rpx;
border-radius: 20rpx;
flex-shrink: 0;
margin-left: 12rpx;
}
.creator-row {
display: flex;
align-items: center;
gap: 16rpx;
margin-top: 12rpx;
}
.creator-nick {
font-size: 26rpx;
color: #6366f1;
}
.create-time {
font-size: 24rpx;
color: #9ca3af;
}
.info-row {
display: flex;
align-items: center;
padding: 14rpx 0;
border-bottom: 1rpx solid #f3f4f6;
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
width: 160rpx;
font-size: 26rpx;
color: #9ca3af;
flex-shrink: 0;
}
.info-value {
flex: 1;
font-size: 26rpx;
color: #374151;
}
.desc-title {
font-size: 28rpx;
font-weight: 600;
color: #1e1e2e;
margin-bottom: 12rpx;
}
.desc-text {
font-size: 26rpx;
color: #4b5563;
line-height: 1.7;
white-space: pre-wrap;
}
.reject-card {
display: flex;
align-items: flex-start;
gap: 8rpx;
background: #fef2f2;
}
.reject-text {
font-size: 26rpx;
color: #ef4444;
}
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.app-title {
font-size: 28rpx;
font-weight: 600;
color: #1e1e2e;
}
.app-list {
margin-top: 16rpx;
}
.app-empty {
text-align: center;
font-size: 26rpx;
color: #9ca3af;
padding: 20rpx 0;
}
.app-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 0;
border-top: 1rpx solid #f3f4f6;
}
.app-info {
flex: 1;
}
.app-name {
font-size: 28rpx;
color: #1e1e2e;
font-weight: 500;
}
.app-msg {
font-size: 24rpx;
color: #6b7280;
display: block;
margin-top: 4rpx;
}
.app-status {
font-size: 22rpx;
display: inline-block;
margin-top: 6rpx;
}
.app-status.pending { color: #f59e0b; }
.app-status.accepted { color: #22c55e; }
.app-status.rejected { color: #ef4444; }
.app-actions {
display: flex;
gap: 12rpx;
flex-shrink: 0;
}
.action-btn {
padding: 8rpx 24rpx;
border-radius: 12rpx;
font-size: 24rpx;
}
.action-btn.accept {
background: #22c55e;
color: #fff;
}
.action-btn.reject {
background: #f3f4f6;
color: #6b7280;
}
.bottom-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: 16rpx;
padding: 20rpx 28rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
background: #fff;
box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.06);
}
.btn {
flex: 1;
text-align: center;
padding: 22rpx 0;
border-radius: 16rpx;
font-size: 28rpx;
font-weight: 600;
}
.btn-apply {
background: #6366f1;
color: #fff;
}
.btn-withdraw {
background: #fef3c7;
color: #d97706;
}
.btn-close {
background: #fee2e2;
color: #ef4444;
}
.btn-edit {
background: #6366f1;
color: #fff;
}
.btn-disabled {
background: #e5e7eb;
color: #9ca3af;
}
.modal-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: 999;
}
.modal-content {
width: 600rpx;
background: #fff;
border-radius: 24rpx;
padding: 40rpx;
}
.modal-title {
font-size: 32rpx;
font-weight: 700;
color: #1e1e2e;
text-align: center;
margin-bottom: 24rpx;
}
.modal-textarea {
width: 100%;
height: 200rpx;
border: 1rpx solid #e5e7eb;
border-radius: 12rpx;
padding: 16rpx;
font-size: 26rpx;
box-sizing: border-box;
}
.modal-actions {
display: flex;
gap: 16rpx;
margin-top: 24rpx;
}
.modal-btn {
flex: 1;
text-align: center;
padding: 20rpx 0;
border-radius: 12rpx;
font-size: 28rpx;
font-weight: 600;
}
.modal-btn.cancel {
background: #f3f4f6;
color: #6b7280;
}
.modal-btn.confirm {
background: #6366f1;
color: #fff;
}
</style>
+456
View File
@@ -0,0 +1,456 @@
<script setup>
import { ref } from "vue";
import { onPullDownRefresh, onReachBottom } from "@dcloudio/uni-app";
import { getShootingList } from "@/api/shooting";
const list = ref([]);
const page = ref(1);
const total = ref(0);
const loading = ref(false);
const finished = ref(false);
const city = ref("");
const style = ref("");
const roleFilter = ref("");
const isFree = ref(null);
const showFilter = ref(false);
const roleOptions = [
{ label: "全部", value: "" },
{ label: "摄影师", value: "photographer" },
{ label: "Coser", value: "cosplayer" },
{ label: "不限", value: "both" },
];
const roleLabels = {
photographer: "找摄影",
cosplayer: "找Coser",
both: "不限",
};
const statusLabels = {
open: "招募中",
matched: "已匹配",
closed: "已关闭",
};
const statusColors = {
open: "#22c55e",
matched: "#f59e0b",
closed: "#9ca3af",
};
async function fetchList(reset = false) {
if (loading.value) return;
if (reset) {
page.value = 1;
finished.value = false;
list.value = [];
}
loading.value = true;
try {
const params = { page: page.value, page_size: 20 };
if (city.value) params.city = city.value;
if (style.value) params.style = style.value;
if (roleFilter.value) params.role_needed = roleFilter.value;
if (isFree.value !== null) params.is_free = isFree.value;
params.status = "open";
const res = await getShootingList(params);
const items = res.items || [];
if (reset) {
list.value = items;
} else {
list.value.push(...items);
}
total.value = res.total || 0;
if (list.value.length >= total.value) finished.value = true;
page.value++;
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
}
function applyFilter() {
showFilter.value = false;
fetchList(true);
}
function resetFilter() {
city.value = "";
style.value = "";
roleFilter.value = "";
isFree.value = null;
showFilter.value = false;
fetchList(true);
}
function goDetail(id) {
uni.navigateTo({ url: `/pages/shooting/detail?id=${id}` });
}
function goCreate() {
uni.navigateTo({ url: "/pages/shooting/create" });
}
function formatDate(d) {
if (!d) return "时间待定";
const dt = new Date(d);
return `${dt.getMonth() + 1}${dt.getDate()}`;
}
function formatBudget(item) {
if (item.is_free) return "互免";
if (item.budget_min && item.budget_max)
return `¥${item.budget_min}-${item.budget_max}`;
if (item.budget_min) return `¥${item.budget_min}`;
if (item.budget_max) return `¥${item.budget_max}以内`;
return "面议";
}
onPullDownRefresh(async () => {
await fetchList(true);
uni.stopPullDownRefresh();
});
onReachBottom(() => {
if (!finished.value) fetchList();
});
fetchList(true);
</script>
<template>
<view class="shooting-page">
<view class="top-bar">
<view class="bar-left">
<text class="bar-title">约拍广场</text>
<text class="bar-count">{{ total }}条约拍</text>
</view>
<view class="bar-right">
<view class="bar-btn" @tap="showFilter = !showFilter">
<uni-icons type="settings" size="18" color="#6366f1" />
<text>筛选</text>
</view>
<view class="bar-btn primary" @tap="goCreate">
<uni-icons type="plusempty" size="16" color="#fff" />
<text>发布</text>
</view>
</view>
</view>
<view v-if="showFilter" class="filter-panel">
<view class="filter-row">
<text class="filter-label">城市</text>
<input
v-model="city"
class="filter-input"
placeholder="输入城市"
/>
</view>
<view class="filter-row">
<text class="filter-label">风格</text>
<input
v-model="style"
class="filter-input"
placeholder="如:古风、JK、洛丽塔"
/>
</view>
<view class="filter-row">
<text class="filter-label">角色</text>
<view class="role-tags">
<view
v-for="opt in roleOptions"
:key="opt.value"
class="role-tag"
:class="{ active: roleFilter === opt.value }"
@tap="roleFilter = opt.value"
>
{{ opt.label }}
</view>
</view>
</view>
<view class="filter-row">
<text class="filter-label">预算</text>
<view class="role-tags">
<view
class="role-tag"
:class="{ active: isFree === null }"
@tap="isFree = null"
>全部</view>
<view
class="role-tag"
:class="{ active: isFree === true }"
@tap="isFree = true"
>互免</view>
<view
class="role-tag"
:class="{ active: isFree === false }"
@tap="isFree = false"
>付费</view>
</view>
</view>
<view class="filter-actions">
<view class="filter-btn reset" @tap="resetFilter">重置</view>
<view class="filter-btn apply" @tap="applyFilter">应用</view>
</view>
</view>
<view class="card-list">
<view
v-for="item in list"
:key="item.id"
class="shoot-card"
@tap="goDetail(item.id)"
>
<view class="card-header">
<view class="card-title-row">
<text class="card-title">{{ item.title }}</text>
<view
class="status-tag"
:style="{ background: statusColors[item.status] || '#9ca3af' }"
>
{{ statusLabels[item.status] || item.status }}
</view>
</view>
<view class="card-creator" v-if="item.creator">
<text class="creator-name">{{ item.creator.nickname }}</text>
</view>
</view>
<view class="card-info">
<view class="info-item">
<uni-icons type="location" size="14" color="#6366f1" />
<text>{{ item.city }}</text>
</view>
<view class="info-item">
<uni-icons type="calendar" size="14" color="#6366f1" />
<text>{{ formatDate(item.shoot_date) }}</text>
</view>
<view class="info-item" v-if="item.style">
<uni-icons type="flag" size="14" color="#6366f1" />
<text>{{ item.style }}</text>
</view>
</view>
<view class="card-footer">
<view class="footer-tag role-label">
{{ roleLabels[item.role_needed] || item.role_needed }}
</view>
<view class="footer-tag budget-label">
{{ formatBudget(item) }}
</view>
<text class="apply-count">{{ item.application_count }}人报名</text>
</view>
</view>
</view>
<view v-if="loading" class="loading-tip">加载中...</view>
<view v-else-if="finished && list.length > 0" class="loading-tip">没有更多了</view>
<view v-else-if="!loading && list.length === 0" class="empty-tip">
<uni-icons type="info" size="40" color="#d1d5db" />
<text>暂无约拍信息</text>
</view>
</view>
</template>
<style scoped>
.shooting-page {
min-height: 100vh;
background: #f5f6fa;
padding-bottom: 30rpx;
}
.top-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 28rpx;
background: #fff;
}
.bar-left {
display: flex;
align-items: baseline;
gap: 12rpx;
}
.bar-title {
font-size: 34rpx;
font-weight: 700;
color: #1e1e2e;
}
.bar-count {
font-size: 24rpx;
color: #9ca3af;
}
.bar-right {
display: flex;
gap: 16rpx;
}
.bar-btn {
display: flex;
align-items: center;
gap: 6rpx;
padding: 10rpx 20rpx;
border-radius: 32rpx;
font-size: 24rpx;
color: #6366f1;
background: #eef2ff;
}
.bar-btn.primary {
background: #6366f1;
color: #fff;
}
.filter-panel {
background: #fff;
padding: 20rpx 28rpx;
margin-bottom: 12rpx;
}
.filter-row {
display: flex;
align-items: center;
margin-bottom: 20rpx;
}
.filter-label {
width: 100rpx;
font-size: 26rpx;
color: #374151;
flex-shrink: 0;
}
.filter-input {
flex: 1;
height: 64rpx;
border: 1rpx solid #e5e7eb;
border-radius: 12rpx;
padding: 0 20rpx;
font-size: 26rpx;
}
.role-tags {
display: flex;
gap: 12rpx;
flex-wrap: wrap;
}
.role-tag {
padding: 8rpx 24rpx;
border-radius: 32rpx;
font-size: 24rpx;
background: #f3f4f6;
color: #374151;
}
.role-tag.active {
background: #6366f1;
color: #fff;
}
.filter-actions {
display: flex;
justify-content: flex-end;
gap: 16rpx;
}
.filter-btn {
padding: 12rpx 36rpx;
border-radius: 12rpx;
font-size: 26rpx;
}
.filter-btn.reset {
background: #f3f4f6;
color: #6b7280;
}
.filter-btn.apply {
background: #6366f1;
color: #fff;
}
.card-list {
padding: 0 20rpx;
}
.shoot-card {
background: #fff;
border-radius: 20rpx;
padding: 28rpx;
margin-top: 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
}
.card-header {
margin-bottom: 16rpx;
}
.card-title-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.card-title {
font-size: 30rpx;
font-weight: 600;
color: #1e1e2e;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-tag {
font-size: 22rpx;
color: #fff;
padding: 4rpx 16rpx;
border-radius: 20rpx;
flex-shrink: 0;
margin-left: 12rpx;
}
.card-creator {
margin-top: 8rpx;
}
.creator-name {
font-size: 24rpx;
color: #9ca3af;
}
.card-info {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
margin-bottom: 16rpx;
}
.info-item {
display: flex;
align-items: center;
gap: 6rpx;
font-size: 24rpx;
color: #6b7280;
}
.card-footer {
display: flex;
align-items: center;
gap: 12rpx;
}
.footer-tag {
font-size: 22rpx;
padding: 4rpx 16rpx;
border-radius: 20rpx;
}
.role-label {
background: #eef2ff;
color: #6366f1;
}
.budget-label {
background: #fef3c7;
color: #d97706;
}
.apply-count {
font-size: 22rpx;
color: #9ca3af;
margin-left: auto;
}
.loading-tip {
text-align: center;
font-size: 26rpx;
color: #9ca3af;
padding: 30rpx 0;
}
.empty-tip {
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
padding: 120rpx 0;
font-size: 28rpx;
color: #9ca3af;
}
</style>
+340
View File
@@ -0,0 +1,340 @@
<script setup>
import { ref } from "vue";
import { onPullDownRefresh, onReachBottom } from "@dcloudio/uni-app";
import { getMyShootings, getMyApplications } from "@/api/shooting";
const tab = ref("published");
const publishedList = ref([]);
const appliedList = ref([]);
const publishedPage = ref(1);
const appliedPage = ref(1);
const publishedTotal = ref(0);
const appliedTotal = ref(0);
const publishedFinished = ref(false);
const appliedFinished = ref(false);
const loading = ref(false);
const roleLabels = {
photographer: "找摄影",
cosplayer: "找Coser",
both: "不限",
};
const statusLabels = {
open: "招募中",
matched: "已匹配",
closed: "已关闭",
};
const statusColors = {
open: "#22c55e",
matched: "#f59e0b",
closed: "#9ca3af",
};
const auditLabels = {
pending: "待审核",
approved: "已通过",
rejected: "已驳回",
};
const auditColors = {
pending: "#f59e0b",
approved: "#22c55e",
rejected: "#ef4444",
};
const appStatusLabels = {
pending: "待处理",
accepted: "已接受",
rejected: "已拒绝",
};
const appStatusColors = {
pending: "#f59e0b",
accepted: "#22c55e",
rejected: "#ef4444",
};
async function fetchPublished(reset = false) {
if (loading.value) return;
if (reset) {
publishedPage.value = 1;
publishedFinished.value = false;
publishedList.value = [];
}
loading.value = true;
try {
const res = await getMyShootings({
page: publishedPage.value,
page_size: 20,
});
const items = res.items || [];
if (reset) {
publishedList.value = items;
} else {
publishedList.value.push(...items);
}
publishedTotal.value = res.total || 0;
if (publishedList.value.length >= publishedTotal.value)
publishedFinished.value = true;
publishedPage.value++;
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
}
async function fetchApplied(reset = false) {
if (loading.value) return;
if (reset) {
appliedPage.value = 1;
appliedFinished.value = false;
appliedList.value = [];
}
loading.value = true;
try {
const res = await getMyApplications({
page: appliedPage.value,
page_size: 20,
});
const items = res.items || [];
if (reset) {
appliedList.value = items;
} else {
appliedList.value.push(...items);
}
appliedTotal.value = res.total || 0;
if (appliedList.value.length >= appliedTotal.value)
appliedFinished.value = true;
appliedPage.value++;
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
}
function switchTab(t) {
tab.value = t;
if (t === "published" && publishedList.value.length === 0) fetchPublished(true);
if (t === "applied" && appliedList.value.length === 0) fetchApplied(true);
}
function goDetail(id) {
uni.navigateTo({ url: `/pages/shooting/detail?id=${id}` });
}
function formatDate(d) {
if (!d) return "";
const dt = new Date(d);
return `${dt.getMonth() + 1}${dt.getDate()}`;
}
onPullDownRefresh(async () => {
if (tab.value === "published") await fetchPublished(true);
else await fetchApplied(true);
uni.stopPullDownRefresh();
});
onReachBottom(() => {
if (tab.value === "published" && !publishedFinished.value) fetchPublished();
if (tab.value === "applied" && !appliedFinished.value) fetchApplied();
});
fetchPublished(true);
</script>
<template>
<view class="mine-shooting-page">
<view class="tabs">
<view
class="tab-item"
:class="{ active: tab === 'published' }"
@tap="switchTab('published')"
>
我发布的
</view>
<view
class="tab-item"
:class="{ active: tab === 'applied' }"
@tap="switchTab('applied')"
>
我报名的
</view>
</view>
<!-- Published list -->
<view v-if="tab === 'published'" class="card-list">
<view
v-for="item in publishedList"
:key="item.id"
class="shoot-card"
@tap="goDetail(item.id)"
>
<view class="card-title-row">
<text class="card-title">{{ item.title }}</text>
<view class="dual-tags">
<view
class="mini-tag"
:style="{ background: auditColors[item.audit_status] || '#9ca3af' }"
>{{ auditLabels[item.audit_status] || item.audit_status }}</view>
<view
class="mini-tag"
:style="{ background: statusColors[item.status] || '#9ca3af' }"
>{{ statusLabels[item.status] || item.status }}</view>
</view>
</view>
<view class="card-sub">
<text>{{ item.city }}</text>
<text v-if="item.style"> · {{ item.style }}</text>
<text> · {{ roleLabels[item.role_needed] || item.role_needed }}</text>
</view>
<view class="card-bottom">
<text class="card-date">{{ formatDate(item.created_at) }}</text>
<text class="apply-count">{{ item.application_count }}人报名</text>
</view>
</view>
<view v-if="loading" class="loading-tip">加载中...</view>
<view v-else-if="publishedFinished && publishedList.length" class="loading-tip">没有更多了</view>
<view v-else-if="!loading && !publishedList.length" class="empty-tip">
<uni-icons type="info" size="40" color="#d1d5db" />
<text>还没有发布约拍</text>
</view>
</view>
<!-- Applied list -->
<view v-if="tab === 'applied'" class="card-list">
<view
v-for="app in appliedList"
:key="app.id"
class="shoot-card"
@tap="goDetail(app.request_id)"
>
<view class="card-title-row">
<text class="card-title">约拍 #{{ app.request_id }}</text>
<view
class="mini-tag"
:style="{ background: appStatusColors[app.status] || '#9ca3af' }"
>{{ appStatusLabels[app.status] || app.status }}</view>
</view>
<view class="card-sub" v-if="app.message">
<text>留言{{ app.message }}</text>
</view>
<view class="card-bottom">
<text class="card-date">{{ formatDate(app.created_at) }}</text>
</view>
</view>
<view v-if="loading" class="loading-tip">加载中...</view>
<view v-else-if="appliedFinished && appliedList.length" class="loading-tip">没有更多了</view>
<view v-else-if="!loading && !appliedList.length" class="empty-tip">
<uni-icons type="info" size="40" color="#d1d5db" />
<text>还没有报名约拍</text>
</view>
</view>
</view>
</template>
<style scoped>
.mine-shooting-page {
min-height: 100vh;
background: #f5f6fa;
}
.tabs {
display: flex;
background: #fff;
border-bottom: 1rpx solid #e5e7eb;
}
.tab-item {
flex: 1;
text-align: center;
padding: 24rpx 0;
font-size: 28rpx;
color: #6b7280;
position: relative;
}
.tab-item.active {
color: #6366f1;
font-weight: 600;
}
.tab-item.active::after {
content: "";
position: absolute;
left: 30%;
right: 30%;
bottom: 0;
height: 4rpx;
background: #6366f1;
border-radius: 4rpx;
}
.card-list {
padding: 0 20rpx;
}
.shoot-card {
background: #fff;
border-radius: 20rpx;
padding: 24rpx 28rpx;
margin-top: 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
}
.card-title-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.card-title {
font-size: 28rpx;
font-weight: 600;
color: #1e1e2e;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dual-tags {
display: flex;
gap: 8rpx;
flex-shrink: 0;
margin-left: 12rpx;
}
.mini-tag {
font-size: 20rpx;
color: #fff;
padding: 2rpx 12rpx;
border-radius: 16rpx;
}
.card-sub {
font-size: 24rpx;
color: #6b7280;
margin-top: 10rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-bottom {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 12rpx;
}
.card-date {
font-size: 22rpx;
color: #9ca3af;
}
.apply-count {
font-size: 22rpx;
color: #6366f1;
}
.loading-tip {
text-align: center;
font-size: 26rpx;
color: #9ca3af;
padding: 30rpx 0;
}
.empty-tip {
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
padding: 120rpx 0;
font-size: 28rpx;
color: #9ca3af;
}
</style>
+651
View File
@@ -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>
+998
View File
@@ -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>
+520
View File
@@ -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>
+365
View File
@@ -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>
+411
View File
@@ -0,0 +1,411 @@
<script setup>
import { ref } from "vue";
import { onLoad, onReachBottom } from "@dcloudio/uni-app";
import { getUserInfo, getUserSpots } from "@/api/user";
import { extractList } from "@/utils/request";
import { resolveImageUrl } from "@/utils/image";
import { formatSpotPrice } from "@/utils/spot";
import Skeleton from "@/components/skeleton/skeleton.vue";
const user = ref(null);
const spots = ref([]);
const loading = ref(true);
const spotsLoading = ref(false);
const page = ref(1);
const pageSize = 10;
const hasMore = ref(true);
let userId = null;
const identityMap = {
photographer: "摄影师",
cosplayer: "Coser",
both: "摄影师 / Coser",
};
onLoad(async (query) => {
if (!query.id) return;
userId = Number(query.id);
try {
user.value = await getUserInfo(userId);
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
fetchSpots(true);
});
const fetchSpots = async (reset = false) => {
if (spotsLoading.value) return;
if (!reset && !hasMore.value) return;
spotsLoading.value = true;
if (reset) {
page.value = 1;
hasMore.value = true;
}
try {
const res = await getUserSpots(userId, {
page: page.value,
page_size: pageSize,
});
const list = extractList(res);
if (reset) {
spots.value = list;
} else {
spots.value.push(...list);
}
if (list.length < pageSize) {
hasMore.value = false;
} else {
page.value++;
}
} catch (e) {
console.error(e);
} finally {
spotsLoading.value = false;
}
};
const goDetail = (id) => {
uni.navigateTo({ url: `/pages/spot/detail?id=${id}` });
};
const loadMore = () => {
if (!spotsLoading.value && hasMore.value) fetchSpots();
};
onReachBottom(() => {
loadMore();
});
</script>
<template>
<view class="user-page">
<view v-if="loading" class="loading-state">
<view class="sk-header shimmer" />
<Skeleton :rows="2" card />
<Skeleton :rows="2" card />
</view>
<template v-else-if="user">
<view class="user-header">
<image
v-if="user.avatar_url"
class="avatar"
:src="resolveImageUrl(user.avatar_url)"
mode="aspectFill"
/>
<view v-else class="avatar avatar-default">
<uni-icons type="person" size="32" color="#ffffff" />
</view>
<text class="nickname">{{ user.nickname }}</text>
<text v-if="user.bio" class="bio">{{ user.bio }}</text>
<view class="meta-row">
<view v-if="user.city" class="meta-item">
<uni-icons type="location" size="14" color="rgba(255,255,255,0.8)" />
<text class="meta-text">{{ user.city }}</text>
</view>
<view v-if="user.identity" class="meta-item">
<uni-icons type="person" size="14" color="rgba(255,255,255,0.8)" />
<text class="meta-text">{{ identityMap[user.identity] || user.identity }}</text>
</view>
</view>
<view class="stats-row">
<view class="stat-item">
<text class="stat-num">{{ spots.length }}</text>
<text class="stat-label">投稿</text>
</view>
</view>
</view>
<view class="section-header">
<text class="section-title">TA 的投稿</text>
</view>
<view class="spot-list">
<view
v-for="item in spots"
:key="item.id"
class="spot-item"
@tap="goDetail(item.id)"
>
<image
v-if="item.cover_image_url"
class="spot-cover"
:src="resolveImageUrl(item.cover_image_url)"
mode="aspectFill"
/>
<view v-else class="spot-cover spot-cover-empty">
<uni-icons type="camera" size="28" color="#94a3b8" />
</view>
<view class="spot-info">
<text class="spot-title">{{ item.title }}</text>
<view class="spot-meta">
<uni-icons type="location" size="14" color="#6366f1" />
<text class="spot-city">{{ item.city }}</text>
</view>
<text
class="spot-price"
:class="{ free: formatSpotPrice(item).isFree, paid: !formatSpotPrice(item).isFree }"
>
{{ formatSpotPrice(item).label }}
</text>
<view v-if="item.avg_rating" class="spot-rating">
<uni-icons type="star-filled" size="14" color="#f59e0b" />
<text class="spot-rating-num">{{ item.avg_rating.toFixed(1) }}</text>
</view>
</view>
<view class="spot-arrow">
<uni-icons type="right" size="16" color="#cbd5e1" />
</view>
</view>
<view v-if="spotsLoading" class="status-tip">
<text>加载中...</text>
</view>
<view v-else-if="!hasMore && spots.length > 0" class="status-tip">
<text>没有更多了</text>
</view>
<view v-else-if="!spotsLoading && spots.length === 0" class="status-tip empty">
<uni-icons type="location" size="40" color="#94a3b8" />
<text class="empty-text">暂无投稿</text>
</view>
</view>
</template>
<view v-else class="error-state">
<text>用户不存在</text>
</view>
</view>
</template>
<style scoped>
.user-page {
min-height: 100vh;
background: #f5f6fa;
}
.loading-state {
padding-top: 0;
}
.sk-header {
width: 100%;
height: 360rpx;
background: linear-gradient(135deg, #c7d2fe, #e0e7ff);
}
.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;
}
.user-header {
background: linear-gradient(135deg, #6366f1, #818cf8);
padding: 48rpx 32rpx 40rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.avatar {
width: 140rpx;
height: 140rpx;
border-radius: 70rpx;
border: 4rpx solid rgba(255, 255, 255, 0.3);
margin-bottom: 16rpx;
}
.avatar-default {
background: rgba(255, 255, 255, 0.25);
display: flex;
align-items: center;
justify-content: center;
}
.nickname {
font-size: 36rpx;
font-weight: 700;
color: #ffffff;
margin-bottom: 8rpx;
}
.bio {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.75);
margin-bottom: 12rpx;
text-align: center;
max-width: 500rpx;
}
.meta-row {
display: flex;
align-items: center;
gap: 24rpx;
margin-bottom: 20rpx;
}
.meta-item {
display: flex;
align-items: center;
gap: 6rpx;
}
.meta-text {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.8);
}
.stats-row {
display: flex;
gap: 48rpx;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
}
.stat-num {
font-size: 36rpx;
font-weight: 700;
color: #ffffff;
}
.stat-label {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.7);
}
.section-header {
padding: 24rpx 32rpx 12rpx;
}
.section-title {
font-size: 30rpx;
font-weight: 700;
color: #1e293b;
}
.spot-list {
padding-bottom: 32rpx;
}
.spot-item {
display: flex;
align-items: center;
background: #ffffff;
margin: 0 24rpx 16rpx;
border-radius: 16rpx;
padding: 20rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
}
.spot-cover {
width: 140rpx;
height: 140rpx;
border-radius: 12rpx;
flex-shrink: 0;
margin-right: 20rpx;
}
.spot-cover-empty {
background: #e2e8f0;
display: flex;
align-items: center;
justify-content: center;
}
.spot-info {
flex: 1;
min-width: 0;
}
.spot-title {
font-size: 28rpx;
font-weight: 600;
color: #1e293b;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 8rpx;
}
.spot-meta {
display: flex;
align-items: center;
gap: 4rpx;
margin-bottom: 6rpx;
}
.spot-city {
font-size: 24rpx;
color: #64748b;
}
.spot-rating {
display: flex;
align-items: center;
gap: 4rpx;
}
.spot-price {
display: block;
margin-bottom: 6rpx;
font-size: 24rpx;
font-weight: 600;
}
.spot-price.free {
color: #16a34a;
}
.spot-price.paid {
color: #d97706;
}
.spot-rating-num {
font-size: 24rpx;
color: #f59e0b;
font-weight: 600;
}
.spot-arrow {
flex-shrink: 0;
margin-left: 8rpx;
}
.status-tip {
text-align: center;
padding: 40rpx 0;
color: #94a3b8;
font-size: 26rpx;
}
.status-tip.empty {
display: flex;
flex-direction: column;
align-items: center;
padding: 60rpx 0;
}
.empty-text {
margin-top: 12rpx;
font-size: 26rpx;
color: #94a3b8;
}
</style>