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

718 lines
16 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-fk.png"
mode="aspectFill"
></image>
<!-- 蒙层(增强对比) -->
<view class="modal-overlay"></view>
<!-- 内容区域 -->
<view class="modal-content" @touchstart.stop @touchend.stop @click.stop>
<!-- 标题:意见反馈 -->
<view class="title-row">
<!-- <text class="modal-title">意见反馈</text> -->
</view>
<!-- 4 个反馈类型 - 单行 -->
<view class="options-grid">
<view
v-for="opt in feedbackOptions"
:key="opt.value"
class="option-item"
:class="{ 'option-item--active': selectedType === opt.value }"
@tap="handleSelectOption(opt)"
>
<text
class="option-text"
:class="{ 'option-text--active': selectedType === 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>
<!-- 标题输入spec §5.4必填max=100 -->
<view class="input-area input-area--compact">
<input
class="input-field input-field--single"
v-model="title"
placeholder="请输入反馈标题(必填,最多 100 字)"
placeholder-class="input-placeholder"
:maxlength="100"
/>
</view>
<!-- 联系方式输入spec §5.4可选max=320 -->
<view class="input-area input-area--compact">
<input
class="input-field input-field--single"
v-model="contact"
placeholder="联系方式(选填,邮箱/手机/微信)"
placeholder-class="input-placeholder"
:maxlength="320"
/>
</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 {
submitFeedbackApi,
uploadLocalFileToOss,
getFeedbackCategoriesApi,
} from "@/utils/api.js";
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["close", "submit"]);
// 反馈类型选项(动态从后端 /moderation/feedback-categories 拉取spec §5.1
// 兜底值仅在首次加载失败时使用
const FALLBACK_FEEDBACK_OPTIONS = [
{ value: "other", label: "其他" },
];
const feedbackOptions = ref([...FALLBACK_FEEDBACK_OPTIONS]);
const categoriesLoading = ref(false);
// 动态拉取反馈分类字典
async function loadFeedbackCategories() {
categoriesLoading.value = true;
try {
const res = await getFeedbackCategoriesApi();
const list = res?.data?.categories;
if (Array.isArray(list) && list.length) {
const sorted = [...list].sort(
(a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)
);
feedbackOptions.value = sorted.map((c) => ({
value: c.code,
label: c.name || c.code,
}));
}
} catch (e) {
console.warn("[FeedbackModal] load feedback categories fail, use fallback", e);
feedbackOptions.value = [...FALLBACK_FEEDBACK_OPTIONS];
} finally {
categoriesLoading.value = false;
}
}
// 首次挂载即拉取;弹窗打开时若已加载则跳过,避免每次开关重复请求
onMounted(loadFeedbackCategories);
watch(
() => props.visible,
(v) => {
if (v && feedbackOptions.value.length <= 1) {
loadFeedbackCategories();
}
}
);
// 状态
const selectedType = ref("");
const title = ref("");
const content = ref("");
const contact = ref("");
const imagePath = ref("");
const imageUrl = ref("");
const submitting = ref(false);
// 是否可提交:必须选择反馈类型 + 标题非空 + 分类加载完成
// spec §5.4:分类 + 标题 + 内容 + 联系方式(可选) + 截图
// 后端 SubmitFeedbackRequestDTO 要求 title 必填max=100content 必填category_code 必填
const canSubmit = computed(() => {
return (
!!selectedType.value &&
title.value.trim().length > 0 &&
title.value.trim().length <= 100 &&
!submitting.value &&
!categoriesLoading.value
);
});
// 关闭相关:与 ReportModal 保持一致的触摸处理,防止误触穿透
let maskTouchStartTime = 0;
const handleMaskTouchStart = (e) => {
maskTouchStartTime = Date.now();
e.preventDefault();
e.stopPropagation();
};
const handleMaskClick = () => {
emit("close");
};
const handleCloseClick = (e) => {
if (e && e.preventDefault) {
e.preventDefault();
e.stopPropagation();
}
handleResetAndClose();
};
const handleResetAndClose = () => {
// 关闭前重置表单
selectedType.value = "";
title.value = "";
content.value = "";
contact.value = "";
imagePath.value = "";
imageUrl.value = "";
submitting.value = false;
emit("close");
};
// 选择反馈类型
const handleSelectOption = (opt) => {
selectedType.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("[FeedbackModal] chooseImage fail", err);
},
});
};
// 移除图片
const handleRemoveImage = () => {
imagePath.value = "";
imageUrl.value = "";
};
// 确认提交
const handleConfirm = async () => {
if (!canSubmit.value) {
if (!selectedType.value) {
uni.showToast({ title: "请选择反馈类型", icon: "none" });
} else if (!title.value.trim()) {
uni.showToast({ title: "请输入反馈标题", icon: "none" });
}
return;
}
// 标题长度兜底校验(与后端 max=100 对齐)
if (title.value.trim().length > 100) {
uni.showToast({ title: "标题不能超过 100 字", icon: "none" });
return;
}
// 联系方式长度兜底校验(与后端 max=320 对齐)
if (contact.value.length > 320) {
uni.showToast({ title: "联系方式过长", icon: "none" });
return;
}
submitting.value = true;
try {
// 如果选择了本地图片,先上传到 OSSspec §5.1:反馈截图 OSS 锁定 feedback/ 前缀)
if (imagePath.value && !imageUrl.value) {
try {
const upRes = await uploadLocalFileToOss(imagePath.value, {
type: "feedback",
});
imageUrl.value = upRes?.imageUrl || "";
} catch (e) {
console.error("[FeedbackModal] upload image fail", e);
uni.showToast({ title: "图片上传失败", icon: "none" });
submitting.value = false;
return;
}
}
// 组装 evidence_keysspec §5.1数组0~5 个 OSS key本 modal 只支持 1 张)
const evidenceKeys = imageUrl.value ? [imageUrl.value] : [];
// 调用反馈 API对齐 backend SubmitFeedbackRequestDTO 必填字段)
const res = await submitFeedbackApi({
category_code: selectedType.value,
title: title.value.trim(),
content: content.value || "",
contact: contact.value.trim(),
is_anonymous: false,
evidence_keys: evidenceKeys,
});
if (res && res.code === 0) {
uni.showToast({ title: "反馈已提交", icon: "success" });
emit("submit", { type: selectedType.value });
handleResetAndClose();
} else {
uni.showToast({
title: (res && res.message) || "提交失败",
icon: "none",
});
}
} catch (e) {
console.error("[FeedbackModal] 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: 110%;
z-index: 2;
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%
);
}
/* 内容区域 */
.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;
}
/* 4 个反馈类型 - 单行 */
.options-grid {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 16rpx 14rpx;
margin-bottom: 24rpx;
}
.option-item {
/* 单行 4 个:扣除 3 个 gap 后均分 */
flex: 0 0 calc((100% - 42rpx) / 4);
height: 64rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
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-area--compact {
height: 76rpx;
margin-bottom: 18rpx;
display: flex;
align-items: center;
}
.input-field--single {
height: 44rpx;
line-height: 44rpx;
}
.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 {
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>