- 复制来源: /home/yueli/track @ 192c8ee (feature/ai-audit-update) - 组织隔离目标: LICA - 端口规划: 前端 8030 / 后端 8031 / 数据库 8032 - 已排除 deploy.sh、deploy_full.sh、docker-compose.prod.yml(IRIS 生产发布脚本) - 已排除工作区未提交改动,取干净的 192c8ee 状态
234 lines
8.9 KiB
Vue
234 lines
8.9 KiB
Vue
<template>
|
||
<view class="page">
|
||
<!-- 问题类型 -->
|
||
<view class="section">
|
||
<text class="section-title">问题类型</text>
|
||
<view class="type-row">
|
||
<view
|
||
v-for="t in FEEDBACK_TYPES"
|
||
:key="t.value"
|
||
:class="['type-chip', form.type === t.value ? 'type-chip-active' : '']"
|
||
@tap="form.type = t.value"
|
||
>{{ t.label }}</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 问题描述 -->
|
||
<view class="section">
|
||
<text class="section-title">详细描述</text>
|
||
<view class="textarea-wrap">
|
||
<textarea
|
||
v-model="form.content"
|
||
class="textarea"
|
||
placeholder="请描述遇到的问题:在哪个页面、做了什么操作、出现什么结果。描述越清楚,我们修得越快。"
|
||
placeholder-class="ta-placeholder"
|
||
:maxlength="MAX_LEN"
|
||
:show-confirm-bar="false"
|
||
auto-height
|
||
/>
|
||
<text class="counter">{{ form.content.length }}/{{ MAX_LEN }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 图片上传(复用 utils/upload.js 的有界并发上传;上限 4 张) -->
|
||
<view class="section">
|
||
<text class="section-title">问题截图<text class="section-sub">(选填,最多 {{ MAX_IMAGES }} 张)</text></text>
|
||
<view class="img-grid">
|
||
<view v-for="(img, i) in form.images" :key="img + '-' + i" class="img-cell">
|
||
<view class="success-badge-wrapper img-frame">
|
||
<image :src="imageUrl(img)" mode="aspectFill" class="img" @tap="previewImage(i)" />
|
||
<view v-if="isUploaded(img)" class="success-badge" />
|
||
</view>
|
||
<text class="img-del" @tap.stop="removeImage(i)">✕</text>
|
||
</view>
|
||
<!-- ⏳ 占位符:每张图成功/失败都会恰好回收一个,避免"以为传好了其实没有" -->
|
||
<view v-for="n in pendingCount" :key="'p' + n" class="img-cell img-loading">
|
||
<text class="img-loading-text">⏳</text>
|
||
</view>
|
||
<view
|
||
v-if="form.images.length + pendingCount < MAX_IMAGES"
|
||
class="img-cell img-add"
|
||
@tap="chooseImages"
|
||
>
|
||
<text class="img-add-icon">📷</text>
|
||
<text class="img-add-text">添加图片</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<button
|
||
class="submit-btn"
|
||
:disabled="!canSubmit"
|
||
@tap="handleSubmit"
|
||
>{{ submitting ? '提交中...' : '提交反馈' }}</button>
|
||
|
||
<view class="hint">提交后我们会尽快处理。若设备故障影响生产,请同时通知当班主管。</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, reactive } from "vue";
|
||
import { FEEDBACK_TYPES, submitFeedback } from "../../api/feedback";
|
||
import { uploadImages, isUploadedUrl } from "../../utils/upload";
|
||
import { getBaseUrl } from "../../utils/request";
|
||
|
||
const MAX_IMAGES = 4;
|
||
const MAX_LEN = 500;
|
||
|
||
const form = reactive({ type: "system", content: "", images: [] });
|
||
const pendingCount = ref(0);
|
||
const uploading = ref(false);
|
||
const submitting = ref(false);
|
||
|
||
const canSubmit = computed(
|
||
() => form.content.trim().length > 0 && !uploading.value && !submitting.value
|
||
);
|
||
|
||
function imageUrl(url) {
|
||
if (!url) return "";
|
||
if (url.startsWith("http")) return url;
|
||
const domain = getBaseUrl().replace(/\/api.*$/, "");
|
||
return domain + (url.startsWith("/") ? url : "/" + url);
|
||
}
|
||
|
||
/** 绿勾角标只认「已上传成功」的项(判定逻辑统一收口在 utils/upload.js) */
|
||
function isUploaded(img) {
|
||
return isUploadedUrl(img);
|
||
}
|
||
|
||
function previewImage(index) {
|
||
uni.previewImage({ urls: form.images.map(imageUrl), current: index });
|
||
}
|
||
|
||
function removeImage(index) {
|
||
form.images.splice(index, 1);
|
||
}
|
||
|
||
async function chooseImages() {
|
||
const maxSlots = MAX_IMAGES - (form.images.length + pendingCount.value);
|
||
if (maxSlots <= 0) return;
|
||
|
||
const chooseRes = await new Promise((resolve, reject) => {
|
||
uni.chooseImage({
|
||
count: maxSlots,
|
||
sizeType: ["compressed"],
|
||
sourceType: ["camera", "album"],
|
||
success: resolve,
|
||
fail: reject,
|
||
});
|
||
}).catch(() => null);
|
||
if (!chooseRes || !chooseRes.tempFilePaths || !chooseRes.tempFilePaths.length) return;
|
||
|
||
const compressedPaths = [];
|
||
for (const p of chooseRes.tempFilePaths) {
|
||
try {
|
||
const compressed = await new Promise((resolve, reject) => {
|
||
uni.compressImage({ src: p, quality: 60, success: resolve, fail: reject });
|
||
});
|
||
compressedPaths.push(compressed.tempFilePath);
|
||
} catch {}
|
||
}
|
||
if (!compressedPaths.length) {
|
||
uni.showToast({ title: "图片处理失败,请重试", icon: "none", duration: 3000 });
|
||
return;
|
||
}
|
||
|
||
uploading.value = true;
|
||
pendingCount.value += compressedPaths.length;
|
||
// 🚀 复用 utils/upload.js 的 3 并发上传池(与追加记录 / 驳回弹窗同一份实现)
|
||
const { failed, total } = await uploadImages(compressedPaths, (url) => {
|
||
if (url) form.images.push(url);
|
||
pendingCount.value--;
|
||
});
|
||
uploading.value = false;
|
||
if (failed > 0) {
|
||
uni.showToast({
|
||
title: failed === total ? "图片上传失败,请重试" : "部分图片上传失败,请重试",
|
||
icon: "none",
|
||
duration: 3000,
|
||
});
|
||
}
|
||
}
|
||
|
||
function readUserId() {
|
||
try {
|
||
let user = uni.getStorageSync("user");
|
||
if (typeof user === "string" && user) user = JSON.parse(user);
|
||
return (user && (user.username || user.id)) || "";
|
||
} catch {
|
||
return "";
|
||
}
|
||
}
|
||
|
||
async function handleSubmit() {
|
||
if (!canSubmit.value) return;
|
||
// 上传还没结束就提交,会把没传完的图片漏掉 —— 拦住并说明原因
|
||
if (pendingCount.value > 0) {
|
||
uni.showToast({ title: "图片还在上传,请稍候", icon: "none" });
|
||
return;
|
||
}
|
||
|
||
submitting.value = true;
|
||
try {
|
||
await submitFeedback({
|
||
type: form.type,
|
||
content: form.content.trim(),
|
||
images: form.images,
|
||
user_id: readUserId(),
|
||
});
|
||
uni.showToast({ title: "感谢反馈", icon: "success" });
|
||
// 让 toast 露个面再返回;被直接打开(无上级页)时退回个人中心,避免卡死
|
||
setTimeout(() => {
|
||
if (getCurrentPages().length > 1) uni.navigateBack();
|
||
else uni.reLaunch({ url: "/pages/profile/index" });
|
||
}, 1200);
|
||
} catch (e) {
|
||
// 校验失败 / 断网等真实错误照常提示;request.js 已弹过通用 toast 就不再重复弹
|
||
console.error("[feedback] 提交失败:", e);
|
||
if (e && e.isNetworkError) {
|
||
uni.showToast({ title: "网络连接失败,请稍后重试", icon: "none", duration: 3000 });
|
||
}
|
||
} finally {
|
||
submitting.value = false;
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.page { min-height: 100vh; padding: 16px; background: #f3f4f6; }
|
||
|
||
.section { background: #fff; border-radius: 14px; padding: 16px; margin-bottom: 14px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
|
||
.section-title { font-size: 14px; font-weight: 700; color: #1f2937; display: block; margin-bottom: 12px; }
|
||
.section-sub { font-size: 12px; font-weight: 400; color: #9ca3af; }
|
||
|
||
/* 问题类型 */
|
||
.type-row { display: flex; gap: 10px; }
|
||
.type-chip { flex: 1; height: 40px; line-height: 40px; text-align: center; border-radius: 10px; background: #f3f4f6; color: #6b7280; font-size: 13px; font-weight: 600; }
|
||
.type-chip-active { background: #2563eb; color: #fff; }
|
||
|
||
/* 描述 */
|
||
.textarea-wrap { position: relative; }
|
||
.textarea { width: 100%; min-height: 110px; border: 1px solid #e5e7eb; border-radius: 10px; padding: 10px 10px 26px; font-size: 14px; line-height: 1.5; box-sizing: border-box; background: #fafbfc; }
|
||
.ta-placeholder { color: #b0b6bf; font-size: 13px; }
|
||
.counter { position: absolute; right: 12px; bottom: 8px; font-size: 11px; color: #b0b6bf; }
|
||
|
||
/* 图片 */
|
||
.img-grid { display: flex; flex-wrap: wrap; }
|
||
.img-cell { position: relative; width: 160rpx; height: 160rpx; margin: 0 16rpx 16rpx 0; }
|
||
/* 圆角与裁剪交给 .success-badge-wrapper,图片只负责填满 */
|
||
.img-frame { width: 160rpx; height: 160rpx; }
|
||
.img { width: 100%; height: 100%; display: block; border: 1px solid #e5e7eb; box-sizing: border-box; background: #e5e7eb; }
|
||
.img-del { position: absolute; top: -12rpx; right: -12rpx; width: 40rpx; height: 40rpx; background: #ef4444; color: #fff; border-radius: 20rpx; font-size: 24rpx; text-align: center; line-height: 40rpx; z-index: 2; }
|
||
.img-loading { display: flex; align-items: center; justify-content: center; background: #f3f4f6; border-radius: 12rpx; border: 1px dashed #d1d5db; }
|
||
.img-loading-text { font-size: 36rpx; }
|
||
.img-add { display: flex; flex-direction: column; align-items: center; justify-content: center; background: #f9fafb; border-radius: 12rpx; border: 1px dashed #d1d5db; }
|
||
.img-add-icon { font-size: 36rpx; }
|
||
.img-add-text { font-size: 20rpx; color: #9ca3af; margin-top: 4rpx; }
|
||
|
||
/* 提交 */
|
||
.submit-btn { width: 100%; height: 46px; line-height: 46px; background: #2563eb; color: #fff; border-radius: 12px; font-size: 15px; font-weight: 600; margin-top: 6px; padding: 0; }
|
||
.submit-btn::after { border: none; }
|
||
.submit-btn[disabled] { opacity: 0.45; }
|
||
.hint { margin-top: 14px; padding: 0 4px; font-size: 11px; color: #b0b6bf; line-height: 1.6; }
|
||
</style>
|