Files
track-LICA/track-uniapp/src/pages/profile/feedback.vue
duxingchen 3286a11bc7 chore: fork from IRIS track 供 LICA 部门独立运行
- 复制来源: /home/yueli/track @ 192c8ee (feature/ai-audit-update)
- 组织隔离目标: LICA
- 端口规划: 前端 8030 / 后端 8031 / 数据库 8032
- 已排除 deploy.sh、deploy_full.sh、docker-compose.prod.yml(IRIS 生产发布脚本)
- 已排除工作区未提交改动,取干净的 192c8ee 状态
2026-09-21 15:56:52 +08:00

234 lines
8.9 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.

<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>