topfans/frontend/pages/components/ReportModal.vue
2026-06-22 17:19:48 +08:00

736 lines
17 KiB
Vue
Raw Permalink 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.

<template>
<view
v-if="visible"
class="modal-wrapper"
@touchmove.stop.prevent
@click.stop
>
<transition name="fade">
<view
v-if="visible"
class="modal-mask"
@touchstart.stop="handleMaskTouchStart"
@click.stop="handleMaskClick"
></view>
</transition>
<transition name="slide-up">
<view v-if="visible" class="modal-container" @click.stop>
<!-- 背景图片(紫色渐变 + 装饰) -->
<image
class="modal-background"
src="/static/assetDetail/Vector.png"
mode="aspectFill"
></image>
<image
class="title-background"
src="/static/assetDetail/topfans-jb.png"
mode="aspectFill"
></image>
<!-- 蒙层(增强对比) -->
<view class="modal-overlay"></view>
<!-- 关闭按钮 -->
<!-- <view class="close-button" @touchstart.stop="handleCloseTouchStart" @touchend.stop="handleCloseTouchEnd"
@click="handleCloseClick">
<image class="back-icon" src="/static/starbookcontent/tuichu.png" mode="aspectFit"></image>
</view> -->
<!-- 内容区域 -->
<view class="modal-content" @touchstart.stop @touchend.stop @click.stop>
<!-- 标题 -->
<view class="title-row">
<!-- <text class="modal-title">举报</text> -->
</view>
<!-- 举报类型选项 3x3 网格 -->
<view class="options-grid">
<view
v-for="(opt, idx) in reportOptions"
:key="opt.value"
class="option-item"
:class="{ 'option-item--active': selectedReason === opt.value }"
@tap="handleSelectOption(opt)"
>
<text
class="option-text"
:class="{ 'option-text--active': selectedReason === opt.value }"
>
{{ opt.label }}
</text>
</view>
</view>
<!-- 上传图片 -->
<view class="upload-area" @tap="handleChooseImage">
<view v-if="!imagePath" class="upload-placeholder">
<view class="upload-icon-circle">
<text class="upload-plus">+</text>
</view>
<text class="upload-label">上传图片</text>
</view>
<view v-else class="upload-preview">
<image
class="upload-image"
:src="imagePath"
mode="aspectFill"
></image>
<view class="upload-remove" @tap.stop="handleRemoveImage">
<text class="upload-remove-text">×</text>
</view>
</view>
</view>
<!-- 文字输入 -->
<view class="input-area">
<textarea
class="input-field"
v-model="content"
placeholder="请输入具体内容"
placeholder-class="input-placeholder"
:maxlength="200"
auto-height
></textarea>
</view>
<!-- 按钮 -->
<view class="button-row">
<view class="action-button back-button" @tap="handleCloseClick">
<text class="action-text">返回</text>
</view>
<view
class="action-button confirm-button"
:class="{ 'action-button--disabled': !canSubmit }"
@tap="handleConfirm"
>
<text class="action-text">{{
submitting ? "提交中" : "确定"
}}</text>
</view>
</view>
</view>
</view>
</transition>
</view>
</template>
<script setup>
import { ref, computed, onMounted, watch } from "vue";
import {
submitReportApi,
uploadLocalFileToOss,
getReportCategoriesApi,
} from "@/utils/api.js";
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
assetId: {
type: [String, Number],
default: "",
},
// 举报对象类型默认藏品spec §5.1asset | user_profile
targetType: {
type: String,
default: "asset",
},
});
const emit = defineEmits(["close", "submit"]);
// 举报类型选项(动态从后端 /moderation/report-categories 拉取spec §5.1
// 兜底值仅在首次加载失败时使用,避免按钮永远 disabled
const FALLBACK_REPORT_OPTIONS = [
{ value: "other", label: "其他" },
];
const reportOptions = ref([...FALLBACK_REPORT_OPTIONS]);
const categoriesLoading = ref(false);
// 状态
const selectedReason = ref("");
const content = ref("");
const imagePath = ref("");
const imageUrl = ref(""); // 已上传到 OSS 的图片 URL对应 evidence_keys[0]
const submitting = ref(false);
const isAnonymous = ref(false);
// 动态拉取举报分类字典
async function loadReportCategories() {
categoriesLoading.value = true;
try {
const res = await getReportCategoriesApi();
const list = res?.data?.categories;
if (Array.isArray(list) && list.length) {
// 保留后端 sort_order将 "other" 兜底保留到最后
const sorted = [...list].sort((a, b) => {
const ao = a.code === "other" ? Number.MAX_SAFE_INTEGER : (a.sort_order ?? 0);
const bo = b.code === "other" ? Number.MAX_SAFE_INTEGER : (b.sort_order ?? 0);
return ao - bo;
});
reportOptions.value = sorted.map((c) => ({
value: c.code,
label: c.name || c.code,
}));
}
} catch (e) {
console.warn("[ReportModal] load report categories fail, use fallback", e);
reportOptions.value = [...FALLBACK_REPORT_OPTIONS];
} finally {
categoriesLoading.value = false;
}
}
// 首次挂载即拉取;弹窗打开时若已加载则跳过,避免每次开关重复请求
onMounted(loadReportCategories);
watch(
() => props.visible,
(v) => {
if (v && reportOptions.value.length <= 1) {
loadReportCategories();
}
}
);
// 是否可提交:必须选择举报类型 + 分类加载完成
const canSubmit = computed(() => {
return !!selectedReason.value && !submitting.value && !categoriesLoading.value;
});
// 关闭相关:与 LikeUsersModal 保持一致的触摸处理,防止误触穿透
let maskTouchStartTime = 0;
const handleMaskTouchStart = (e) => {
maskTouchStartTime = Date.now();
e.preventDefault();
e.stopPropagation();
};
const handleMaskClick = () => {
emit("close");
};
let closeTouchStartTime = 0;
let closeTouchLocked = false;
const handleCloseTouchStart = (e) => {
closeTouchStartTime = Date.now();
closeTouchLocked = false;
};
const handleCloseTouchEnd = (e) => {
if (closeTouchLocked) return;
closeTouchLocked = true;
const touchDuration = Date.now() - closeTouchStartTime;
if (touchDuration < 300) {
e.preventDefault();
e.stopPropagation();
handleResetAndClose();
}
};
const handleCloseClick = (e) => {
if (e && e.preventDefault) {
e.preventDefault();
e.stopPropagation();
}
handleResetAndClose();
};
const handleResetAndClose = () => {
// 关闭前重置表单
selectedReason.value = "";
content.value = "";
imagePath.value = "";
imageUrl.value = "";
submitting.value = false;
emit("close");
};
// 选择举报类型
const handleSelectOption = (opt) => {
selectedReason.value = opt.value;
};
// 选择图片
const handleChooseImage = () => {
uni.chooseImage({
count: 1,
sizeType: ["compressed"],
sourceType: ["album", "camera"],
success: (res) => {
const tempFile = res.tempFilePaths?.[0];
if (tempFile) {
imagePath.value = tempFile;
}
},
fail: (err) => {
console.warn("[ReportModal] chooseImage fail", err);
},
});
};
// 移除图片
const handleRemoveImage = () => {
imagePath.value = "";
imageUrl.value = "";
};
// 确认提交
const handleConfirm = async () => {
if (!canSubmit.value) {
if (!selectedReason.value) {
uni.showToast({ title: "请选择举报类型", icon: "none" });
}
return;
}
// 描述长度兜底校验spec §5.1 ≤ 500
if (content.value && content.value.length > 500) {
uni.showToast({ title: "描述不能超过 500 字", icon: "none" });
return;
}
submitting.value = true;
try {
// 如果选择了本地图片但还没上传到 OSS先直传
if (imagePath.value && !imageUrl.value) {
try {
const upRes = await uploadLocalFileToOss(imagePath.value, {
type: "asset",
});
imageUrl.value = upRes?.imageUrl || "";
} catch (e) {
console.error("[ReportModal] upload image fail", e);
uni.showToast({ title: "图片上传失败", icon: "none" });
submitting.value = false;
return;
}
}
// 组装 evidence_keysspec §5.1数组0~5 个 OSS key
const evidenceKeys = imageUrl.value ? [imageUrl.value] : [];
// 调用举报 APIspec §5.1 新字段)
const res = await submitReportApi({
target_type: props.targetType || "asset",
target_id: Number(props.assetId) || props.assetId,
category_code: selectedReason.value,
description: content.value || "",
is_anonymous: !!isAnonymous.value,
evidence_keys: evidenceKeys,
});
// request 拦截器在 code !== 0 时会 reject能走到这里说明 res.code === 0
if (res?.data?.target_hidden) {
uni.showToast({
title: "已自动隐藏,等待审核",
icon: "none",
duration: 2000,
});
} else {
uni.showToast({ title: "举报成功", icon: "success", duration: 1500 });
}
emit("submit", {
reason: selectedReason.value,
target_hidden: !!res?.data?.target_hidden,
});
handleResetAndClose();
} catch (e) {
console.error("[ReportModal] submit fail", e);
uni.showToast({ title: e?.message || "举报失败", icon: "none" });
} finally {
submitting.value = false;
}
};
</script>
<style scoped>
.modal-wrapper {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.modal-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 1;
}
.modal-container {
position: relative;
width: 680rpx;
max-height: 85vh;
border-radius: 32rpx;
overflow: hidden;
z-index: 2;
box-shadow: 0 12rpx 40rpx rgba(0, 0, 0, 0.5);
}
.modal-background {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
z-index: 0;
object-fit: cover;
}
.title-background {
position: absolute;
top: -224rpx;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 80%;
z-index: 0;
object-fit: cover;
}
.modal-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
z-index: 1;
background: linear-gradient(
180deg,
rgba(75, 30, 110, 0.35) 0%,
rgba(131, 75, 158, 0.55) 100%
);
}
/* 关闭按钮 */
.close-button {
position: absolute;
top: 16rpx;
right: 16rpx;
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.back-icon {
width: 36rpx;
height: 36rpx;
}
/* 内容区域 */
.modal-content {
position: relative;
z-index: 2;
padding: 248rpx 36rpx 36rpx;
display: flex;
flex-direction: column;
}
/* 标题 */
.title-row {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24rpx;
}
.modal-title {
font-size: 36rpx;
font-weight: bold;
color: #fff9e7;
font-family: "yt", sans-serif;
text-shadow: -1rpx 1rpx 4rpx rgba(0, 0, 0, 0.84);
letter-spacing: 4rpx;
}
/* 9 个举报类型 - 3 列网格 */
.options-grid {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 16rpx 14rpx;
margin-bottom: 24rpx;
}
.option-item {
flex: 0 0 calc((100% - 28rpx) / 3);
height: 64rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
/* background: linear-gradient(
168.9deg,
rgba(154, 146, 255, 0.48) 4.58%,
rgba(255, 202, 229, 0.48) 43.91%,
rgba(255, 250, 253, 0.475) 76.13%,
rgba(211, 209, 255, 0.48) 91.61%
);
box-shadow:
0 4rpx 4rpx rgba(182, 48, 48, 0.25),
0 2rpx 3rpx rgba(212, 39, 39, 0.31);
backdrop-filter: blur(0.6px); */
background: url("/static/assetDetail/text-bj.png") center no-repeat;
background-size: cover;
transition: all 0.2s ease;
box-shadow:
0 4px 4px 0 rgba(182, 48, 48, 0.25),
0 2px 0px 0 rgba(212, 39, 39, 0.31);
}
.option-item:active {
transform: scale(0.96);
opacity: 0.85;
}
.option-item--active {
background: linear-gradient(
165deg,
#f0e4b1 0%,
#f08399 50%,
#b94e73 90%,
#834b9e 100%
);
box-shadow:
0 4rpx 12rpx rgba(255, 143, 158, 0.4),
inset 0 2rpx 4rpx rgba(255, 255, 255, 0.45);
transform: scale(1.04);
}
.option-text {
font-size: 26rpx;
color: #fff9e7;
font-family: "yt", sans-serif;
text-shadow: -1rpx 1rpx 4rpx rgba(0, 0, 0, 0.84);
white-space: nowrap;
}
.option-text--active {
color: #ffffff;
font-weight: bold;
}
/* 上传图片区域 */
.upload-area {
height: 120rpx;
border-radius: 16rpx;
margin-bottom: 20rpx;
background: linear-gradient(
168.9deg,
rgba(154, 146, 255, 0.48) 4.58%,
rgba(255, 202, 229, 0.48) 43.91%,
rgba(255, 250, 253, 0.475) 76.13%,
rgba(211, 209, 255, 0.48) 91.61%
);
box-shadow: 2rpx 4rpx 1.8rpx rgba(214, 41, 41, 0.12);
backdrop-filter: blur(0.6px);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.upload-area:active {
opacity: 0.85;
}
.upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8rpx;
}
.upload-icon-circle {
width: 44rpx;
height: 44rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.25);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.25);
}
.upload-plus {
font-size: 36rpx;
color: #fff9e7;
line-height: 1;
font-weight: 300;
margin-top: -4rpx;
text-shadow: -1rpx 1rpx 4rpx rgba(0, 0, 0, 0.84);
}
.upload-label {
font-size: 24rpx;
color: #fff9e7;
font-family: "yt", sans-serif;
opacity: 0.85;
text-shadow: -1rpx 1rpx 4rpx rgba(0, 0, 0, 0.84);
}
.upload-preview {
position: relative;
width: 100%;
height: 100%;
}
.upload-image {
width: 100%;
height: 100%;
}
.upload-remove {
position: absolute;
top: 6rpx;
right: 6rpx;
width: 40rpx;
height: 40rpx;
border-radius: 50%;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
}
.upload-remove-text {
font-size: 32rpx;
color: #fff;
line-height: 1;
margin-top: -4rpx;
}
/* 文字输入 */
.input-area {
height: 100rpx;
border-radius: 16rpx;
margin-bottom: 32rpx;
background: linear-gradient(
168.9deg,
rgba(154, 146, 255, 0.48) 4.58%,
rgba(255, 202, 229, 0.48) 43.91%,
rgba(255, 250, 253, 0.475) 76.13%,
rgba(211, 209, 255, 0.48) 91.61%
);
box-shadow: 2rpx 4rpx 1.8rpx rgba(214, 41, 41, 0.12);
backdrop-filter: blur(0.6px);
padding: 16rpx 24rpx;
}
.input-field {
width: 100%;
height: 100%;
font-size: 26rpx;
color: #fff9e7;
font-family: "yt", sans-serif;
line-height: 1.5;
text-shadow: -1rpx 1rpx 4rpx rgba(0, 0, 0, 0.84);
}
.input-placeholder {
color: rgba(255, 249, 231, 0.6) !important;
font-size: 26rpx;
}
/* 按钮 */
.button-row {
display: flex;
align-items: center;
justify-content: center;
gap: 24rpx;
padding: 0 12rpx;
}
.action-button {
/* flex: 1; */
width: 192rpx;
height: 72rpx;
border-radius: 36rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 1rpx 2rpx 5.1rpx rgba(225, 33, 33, 0.32);
transition: all 0.2s ease;
}
.action-button:active {
transform: scale(0.96);
opacity: 0.85;
}
.action-button--disabled {
opacity: 0.55;
}
.back-button {
opacity: 0.82;
background: linear-gradient(
116deg,
rgba(246, 233, 180, 0.2) -19.66%,
rgba(240, 131, 153, 0.2) 70.92%,
rgba(213, 107, 109, 0.17) 138.79%,
rgba(105, 209, 230, 0.14) 198.61%
);
box-shadow: 0 1px 4px 0 rgba(255, 255, 255, 0.5);
}
.confirm-button {
opacity: 0.82;
background: linear-gradient(
116deg,
#f6e9b4 -19.66%,
#f08399 70.92%,
rgba(213, 107, 109, 0.84) 138.79%,
rgba(105, 209, 230, 0.69) 198.61%
);
box-shadow: 1px 2px 5.1px 0 rgba(225, 33, 33, 0.32);
}
.action-text {
font-size: 30rpx;
color: #fff9e7;
font-family: "yt", sans-serif;
font-weight: bold;
text-shadow: -1rpx 1rpx 4rpx rgba(191, 61, 61, 0.84);
letter-spacing: 2rpx;
}
/* 动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-up-enter-active,
.slide-up-leave-active {
transition: all 0.3s ease;
}
.slide-up-enter-from,
.slide-up-leave-to {
opacity: 0;
transform: translateY(80rpx);
}
</style>