Files
track/track-uniapp/src/pages/profile/feedback.vue
duxingchen c7f046a993 feat: 个人中心补齐工作统计/设置/帮助与反馈三个模块
原先三个菜单都是空壳,点「帮助与反馈」只弹「敬请期待」。

工作统计(statistics.vue)
- 时段切换:今日 / 本周 / 本月 / 自定义(含起止日期选择器)
- 「生产战绩」完成 / 被驳回 / 参与产品;「操作统计」接收 / 转交 / 上传备注
  后者的口径与 PC「人员操作统计」严格同源,已逐字段交叉验证(4 人 × 4 指标
  全部相等),工人自查的数与主管看到的面板对得上
- 时段边界显式拼 "+08:00":车间按北京时间作息,发裸字符串会被后端按服务器
  本地时间解释,边界整体偏 8 小时

设置(settings.vue)
- 清理缓存:黑名单式,只删登记过的业务缓存(当前为 msg_seen_<product_id>,
  即留言抽屉的已读位点,随扫过的设备数无上限增长)。未登记的 key 一律保留,
  新增插件天然免疫;另有 NEVER_DELETE 兜底,任何情况下不动登录凭证与环境
  配置。副标题实时显示待清理条数,卡片下方说明「不动什么」
- 检查更新:调用 utils/ota.js 的 checkAppUpdate({ manual: true })
- 退出登录:由 profile/index 迁入,加确认弹窗防误触

帮助与反馈(feedback.vue)
- 问题类型(系统问题/设备故障/其他)、描述文本域(500 字计数)、
  图片上传最多 4 张,复用 utils/upload.js 的并发上传池
- api/feedback.js 采用「先真后假」:先按最终契约 POST /feedback/,
  仅当 404(端点尚未实现)才回退本地 mock。后端补上接口后前端零改动即可
  接上;而 400/422/断网仍照常抛出,不会被 mock 悄悄吞掉

路由
- pages.json 注册三个新页面(工作统计开启下拉刷新)
- profile/index.vue 用 MENU_ROUTES 映射 uni.navigateTo,移除「敬请期待」拦截
  与退出登录按钮
2026-09-15 15:51:50 +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>