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

573 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { ref, computed } from "vue";
import { onLoad } 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>