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,移除「敬请期待」拦截
与退出登录按钮
This commit is contained in:
37
track-uniapp/src/api/feedback.js
Normal file
37
track-uniapp/src/api/feedback.js
Normal file
@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 帮助与反馈 API。
|
||||
*
|
||||
* ⚠️ 后端目前【没有】feedback 接口(已核对 openapi 路由表)。这里不直接写死
|
||||
* 一个假函数,而是「先真后假」:先按最终契约 POST /feedback/,只有拿到
|
||||
* 404(端点尚未实现)才回退到本地 mock 成功。这样等后端补上接口后,前端
|
||||
* 一行都不用改就能自动接上真实链路;而 400/422/断网等真实错误仍会照常
|
||||
* 抛出,不会被 mock 悄悄吞掉 —— 否则工人会以为反馈提交成功了,其实没有。
|
||||
*/
|
||||
import request from "../utils/request";
|
||||
|
||||
/** 反馈问题类型(与页面选择项一一对应) */
|
||||
export const FEEDBACK_TYPES = [
|
||||
{ value: "system", label: "系统问题" },
|
||||
{ value: "device", label: "设备故障" },
|
||||
{ value: "other", label: "其他" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 提交一条反馈。
|
||||
* @param {{type: string, content: string, images?: string[], user_id?: string}} payload
|
||||
* @returns {Promise<{id?: string, mocked?: boolean}>}
|
||||
*/
|
||||
export async function submitFeedback(payload) {
|
||||
try {
|
||||
// silent:404 会走到下面的 mock 分支,不能先弹一句「请求失败 (404)」再弹「感谢反馈」
|
||||
return await request({ url: "/feedback/", method: "POST", data: payload, silent: true });
|
||||
} catch (e) {
|
||||
// 只认「端点不存在」这一种情况回退 mock;其余错误(校验失败/网络断)
|
||||
// 必须原样抛给页面去提示,不能让用户误以为提交成功
|
||||
if (e && e.statusCode === 404) {
|
||||
console.warn("[feedback] 后端暂无 /feedback/ 接口,回退本地 mock");
|
||||
return { id: null, mocked: true };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
27
track-uniapp/src/api/stats.js
Normal file
27
track-uniapp/src/api/stats.js
Normal file
@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 个人效能统计 API — 「个人中心 → 工作统计」数据源。
|
||||
*
|
||||
* 后端 /dashboard/* 系列是「上帝视角」(全厂数据、不按人过滤),且
|
||||
* people-history 只返回 WIP/PENDING/COMPLETED、rejected-tasks 压根没有
|
||||
* assignee_id 参数 —— 一线工人要看的「我的今日被驳回数」在这两个接口里
|
||||
* 都拿不到。故后端补了一个薄桥接端点 /dashboard/my-stats,沿用同一套统计
|
||||
* 口径,但强制按 assignee_id 过滤到本人。
|
||||
*/
|
||||
import { get } from "../utils/request";
|
||||
|
||||
/**
|
||||
* 获取当前登录用户的个人效能统计(支持自选时段)。
|
||||
*
|
||||
* 返回两组指标:
|
||||
* 生产战绩 — tasks_completed / tasks_rejected / products_touched
|
||||
* 操作统计 — receive_count / transfer_count / record_count / op_total
|
||||
* (与 PC「人员操作统计」严格同口径,已逐字段交叉验证)
|
||||
*
|
||||
* @param {string} assigneeId - 负责人ID(即登录用户的 username)
|
||||
* @param {string} [since] - 起始时间 ISO,**必须带时区偏移**(如 2026-09-01T00:00:00+08:00)
|
||||
* @param {string} [until] - 截止时间 ISO,同上
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export function getMyStats(assigneeId, since, until) {
|
||||
return get("/dashboard/my-stats", { assignee_id: assigneeId, since, until });
|
||||
}
|
||||
@ -36,7 +36,8 @@
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的任务",
|
||||
"navigationBarBackgroundColor": "#2563EB",
|
||||
"navigationBarTextStyle": "white"
|
||||
"navigationBarTextStyle": "white",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -44,7 +45,8 @@
|
||||
"style": {
|
||||
"navigationBarTitleText": "消息通知",
|
||||
"navigationBarBackgroundColor": "#2563EB",
|
||||
"navigationBarTextStyle": "white"
|
||||
"navigationBarTextStyle": "white",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -54,6 +56,31 @@
|
||||
"navigationBarBackgroundColor": "#2563EB",
|
||||
"navigationBarTextStyle": "white"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/profile/statistics",
|
||||
"style": {
|
||||
"navigationBarTitleText": "工作统计",
|
||||
"navigationBarBackgroundColor": "#2563EB",
|
||||
"navigationBarTextStyle": "white",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/profile/settings",
|
||||
"style": {
|
||||
"navigationBarTitleText": "设置",
|
||||
"navigationBarBackgroundColor": "#2563EB",
|
||||
"navigationBarTextStyle": "white"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/profile/feedback",
|
||||
"style": {
|
||||
"navigationBarTitleText": "帮助与反馈",
|
||||
"navigationBarBackgroundColor": "#2563EB",
|
||||
"navigationBarTextStyle": "white"
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
|
||||
233
track-uniapp/src/pages/profile/feedback.vue
Normal file
233
track-uniapp/src/pages/profile/feedback.vue
Normal file
@ -0,0 +1,233 @@
|
||||
<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>
|
||||
@ -15,7 +15,6 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<button class="logout-btn" @tap="handleLogout">退出登录</button>
|
||||
<view class="version">{{ appVersion }}</view>
|
||||
</view>
|
||||
</template>
|
||||
@ -36,28 +35,26 @@ onMounted(() => {
|
||||
} catch {}
|
||||
});
|
||||
|
||||
function handleMenuClick(item) {
|
||||
switch (item) {
|
||||
case "关于":
|
||||
uni.showModal({
|
||||
title: "关于 Track",
|
||||
content: "Track 生产流转管理系统\n当前版本:" + appVersion.value + "\n核心架构:FastAPI + Vue3 + uni-app",
|
||||
showCancel: false,
|
||||
});
|
||||
break;
|
||||
case "帮助与反馈":
|
||||
uni.showToast({ title: "反馈通道搭建中,敬请期待...", icon: "none" });
|
||||
break;
|
||||
// 工作统计、设置 暂不处理
|
||||
}
|
||||
}
|
||||
// 菜单项 → 子页面路由。退出登录已迁到「设置」页,此处不再保留按钮
|
||||
const MENU_ROUTES = {
|
||||
"工作统计": "/pages/profile/statistics",
|
||||
"设置": "/pages/profile/settings",
|
||||
"帮助与反馈": "/pages/profile/feedback",
|
||||
};
|
||||
|
||||
function handleLogout() {
|
||||
uni.removeStorageSync("token");
|
||||
uni.removeStorageSync("access_token");
|
||||
uni.removeStorageSync("refresh_token");
|
||||
uni.removeStorageSync("user");
|
||||
uni.reLaunch({ url: "/pages/login/login" });
|
||||
function handleMenuClick(item) {
|
||||
const url = MENU_ROUTES[item];
|
||||
if (url) {
|
||||
uni.navigateTo({ url });
|
||||
return;
|
||||
}
|
||||
if (item === "关于") {
|
||||
uni.showModal({
|
||||
title: "关于 Track",
|
||||
content: "Track 生产流转管理系统\n当前版本:" + appVersion.value + "\n核心架构:FastAPI + Vue3 + uni-app",
|
||||
showCancel: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -72,6 +69,5 @@ function handleLogout() {
|
||||
.menu-item:last-child { border-bottom: none; }
|
||||
.menu-text { font-size: 14px; color: #374151; }
|
||||
.menu-arrow { font-size: 18px; color: #d1d5db; }
|
||||
.logout-btn { width: 100%; height: 44px; background: #fff; color: #dc2626; border: 1px solid #fecaca; border-radius: 10px; font-size: 14px; margin-top: 24px; line-height: 44px; }
|
||||
.version { text-align: center; font-size: 11px; color: #d1d5db; margin-top: 16px; }
|
||||
.version { text-align: center; font-size: 11px; color: #d1d5db; margin-top: 24px; }
|
||||
</style>
|
||||
|
||||
214
track-uniapp/src/pages/profile/settings.vue
Normal file
214
track-uniapp/src/pages/profile/settings.vue
Normal file
@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="menu-card">
|
||||
<view class="menu-item" @tap="handleClearCache">
|
||||
<view class="menu-labels">
|
||||
<text class="menu-text">清理缓存</text>
|
||||
<text class="menu-sub">{{ cacheSummary }}</text>
|
||||
</view>
|
||||
<view class="menu-right">
|
||||
<text class="menu-value">{{ cacheSize }}</text>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="menu-item" @tap="handleCheckUpdate">
|
||||
<view class="menu-labels">
|
||||
<text class="menu-text">检查更新</text>
|
||||
<text class="menu-sub">热更新资源包,无需重装 App</text>
|
||||
</view>
|
||||
<view class="menu-right">
|
||||
<text class="menu-value">{{ appVersion }}</text>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 明确告诉工人「清的是什么、不动什么」—— 否则点了没变化会以为没生效 -->
|
||||
<view class="hint">
|
||||
清理仅删除留言已读记录等临时数据,不会影响登录状态、后端地址配置,也不会删除已上传的照片。
|
||||
</view>
|
||||
|
||||
<button class="logout-btn" @tap="handleLogout">退出登录</button>
|
||||
|
||||
<view class="version">Track 生产流转 {{ appVersion }}</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { checkAppUpdate } from "../../utils/ota";
|
||||
|
||||
// ============================================================
|
||||
// 缓存清理策略 —— 黑名单式:只删「明确登记过的业务缓存」
|
||||
// ============================================================
|
||||
//
|
||||
// 不用白名单(遍历时跳过白名单、其余全删):那样每引入一个新的插件 / SDK,
|
||||
// 只要它在本地存了配置,就会被无声清掉,属于「默认伤害」。
|
||||
// 黑名单反过来 —— **没被匹配到的 key 一律保留**,新增插件天然免疫。
|
||||
//
|
||||
// ⚠️ 新增业务缓存时,请顺手到下面登记前缀,不要在这里写零散判断。
|
||||
|
||||
/** 按前缀匹配的可清理缓存 */
|
||||
const CACHE_KEY_PREFIXES = [
|
||||
// ── 当前真实在用 ──────────────────────────────────────────
|
||||
// 留言抽屉的「已读位点」,scan/detail.vue 里每打开一台设备的留言就写一个
|
||||
// msg_seen_<product_id>,随工人扫过的设备数无上限增长。这是目前全 App 唯一
|
||||
// 真正会无限膨胀的业务缓存。清掉只影响未读小红点会重新出现,无业务损失。
|
||||
"msg_seen_",
|
||||
|
||||
// ── 预留:以下前缀当前无数据,供新功能落缓存时对齐命名 ──────
|
||||
"draft_", // 各页面临时保存的草稿
|
||||
"cache_", // 通用业务缓存
|
||||
"search_", // 历史搜索
|
||||
"history_", // 操作历史
|
||||
"scan_", // 扫码记录
|
||||
"recent_", // 最近访问
|
||||
"tmp_", // 临时数据
|
||||
"upload_", // 未完成上传的图片本地记录
|
||||
];
|
||||
|
||||
/** 无前缀可循、需整键精确匹配的(当前为空,留作扩展位) */
|
||||
const CACHE_KEYS_EXACT = new Set();
|
||||
|
||||
/**
|
||||
* 无论如何都不允许删除的 key —— 最后一道保险。
|
||||
* 即使将来有人加了与它们撞车的前缀规则(比如为「user_viewed_tips」加了
|
||||
* `user_`),也不至于把登录凭证或环境配置连带清掉。
|
||||
*/
|
||||
const NEVER_DELETE = new Set(["access_token", "refresh_token", "token", "user", "env_base_url"]);
|
||||
|
||||
function isCacheKey(key) {
|
||||
if (NEVER_DELETE.has(key)) return false;
|
||||
if (CACHE_KEYS_EXACT.has(key)) return true;
|
||||
return CACHE_KEY_PREFIXES.some((prefix) => key.startsWith(prefix));
|
||||
}
|
||||
|
||||
const appVersion = ref("T1.0.2");
|
||||
const cacheSize = ref("—");
|
||||
const cacheCount = ref(0); // 当前可清理的临时数据条数
|
||||
|
||||
/**
|
||||
* 「清理缓存」的副标题 —— 直接回答「到底清的是什么」。
|
||||
* 原先只有一个按钮,工人点完看不到任何变化,会怀疑没生效。
|
||||
*/
|
||||
const cacheSummary = computed(() => {
|
||||
if (cacheCount.value > 0) return `${cacheCount.value} 项临时数据(留言已读记录等)`;
|
||||
return "暂无临时数据可清理";
|
||||
});
|
||||
|
||||
function readAppVersion() {
|
||||
try {
|
||||
const sysInfo = uni.getSystemInfoSync();
|
||||
return sysInfo.appWgtVersion || sysInfo.appVersion || "T1.0.2";
|
||||
} catch {
|
||||
return "T1.0.2";
|
||||
}
|
||||
}
|
||||
|
||||
/** 刷新缓存信息:占用体积 + 可清理条数 —— 让「清理缓存」有个看得见的效果,而不是盲点一下 */
|
||||
function refreshCacheSize() {
|
||||
try {
|
||||
const info = uni.getStorageInfoSync() || {};
|
||||
const kb = typeof info.currentSize === "number" ? info.currentSize : null;
|
||||
cacheSize.value = kb == null ? "—" : kb < 1024 ? `${kb} KB` : `${(kb / 1024).toFixed(1)} MB`;
|
||||
cacheCount.value = (info.keys || []).filter(isCacheKey).length;
|
||||
} catch {
|
||||
cacheSize.value = "—";
|
||||
cacheCount.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理缓存 —— 逐键判定:只清 isCacheKey() 认得出来的业务缓存。
|
||||
*
|
||||
* 其余一律保留,包括登录态、环境配置,以及任何未登记的 key。
|
||||
* 逐项 try/catch,单项失败不影响其余项继续清理。
|
||||
*/
|
||||
function handleClearCache() {
|
||||
let keys = [];
|
||||
try {
|
||||
keys = (uni.getStorageInfoSync() || {}).keys || [];
|
||||
} catch (e) {
|
||||
console.error("[settings] 读取缓存列表失败:", e);
|
||||
uni.showToast({ title: "清理失败,请重试", icon: "none" });
|
||||
return;
|
||||
}
|
||||
|
||||
let removed = 0;
|
||||
let failed = 0;
|
||||
for (const key of keys) {
|
||||
if (!isCacheKey(key)) continue; // 未登记的一律保留(登录态、环境配置、未来插件配置)
|
||||
try {
|
||||
uni.removeStorageSync(key);
|
||||
removed++;
|
||||
} catch (e) {
|
||||
// 单项失败不影响其余项继续清理
|
||||
failed++;
|
||||
console.error("[settings] 清除缓存项失败:", key, e);
|
||||
}
|
||||
}
|
||||
console.log(`[settings] 缓存清理完成:已清除 ${removed} 项,失败 ${failed} 项,保留 ${keys.length - removed} 项`);
|
||||
|
||||
refreshCacheSize();
|
||||
if (failed > 0) {
|
||||
uni.showToast({ title: `清理完成,${failed} 项未能清除`, icon: "none" });
|
||||
} else if (removed === 0) {
|
||||
// 一项都没删就别报「清理成功」—— 那是在骗用户
|
||||
uni.showToast({ title: "暂无可清理的缓存", icon: "none" });
|
||||
} else {
|
||||
uni.showToast({ title: "清理成功", icon: "success" });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCheckUpdate() {
|
||||
// manual = true:跳过节流并给出「已是最新 / 检查失败」的明确反馈 ——
|
||||
// 用户主动点的按钮必须有个回音(自动检查才静默收场)
|
||||
await checkAppUpdate({ manual: true });
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
// 退出是不可逆的(要重新输账号密码),按车间使用场景加一道确认防误触
|
||||
uni.showModal({
|
||||
title: "退出登录",
|
||||
content: "退出后需要重新输入账号密码,确定退出吗?",
|
||||
confirmText: "退出",
|
||||
cancelText: "取消",
|
||||
success: (res) => {
|
||||
if (!res.confirm) return;
|
||||
try {
|
||||
uni.removeStorageSync("token");
|
||||
uni.removeStorageSync("access_token");
|
||||
uni.removeStorageSync("refresh_token");
|
||||
uni.removeStorageSync("user");
|
||||
} catch {}
|
||||
uni.reLaunch({ url: "/pages/login/login" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
appVersion.value = readAppVersion();
|
||||
refreshCacheSize();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { min-height: 100vh; padding: 16px; background: #f3f4f6; }
|
||||
|
||||
.menu-card { background: #fff; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); overflow: hidden; }
|
||||
.menu-item { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid #f3f4f6; }
|
||||
.menu-item:last-child { border-bottom: none; }
|
||||
.menu-labels { display: flex; flex-direction: column; gap: 3px; }
|
||||
.menu-text { font-size: 14px; color: #374151; }
|
||||
.menu-sub { font-size: 11px; color: #9ca3af; }
|
||||
|
||||
.hint { margin: 14px 4px 0; font-size: 11px; color: #b0b6bf; line-height: 1.6; }
|
||||
.menu-right { display: flex; align-items: center; gap: 6px; }
|
||||
.menu-value { font-size: 12px; color: #9ca3af; }
|
||||
.menu-arrow { font-size: 18px; color: #d1d5db; }
|
||||
|
||||
.logout-btn { width: 100%; height: 44px; background: #fff; color: #dc2626; border: 1px solid #fecaca; border-radius: 10px; font-size: 14px; margin-top: 24px; line-height: 44px; }
|
||||
.logout-btn::after { border: none; }
|
||||
.version { text-align: center; font-size: 11px; color: #d1d5db; margin-top: 16px; }
|
||||
</style>
|
||||
290
track-uniapp/src/pages/profile/statistics.vue
Normal file
290
track-uniapp/src/pages/profile/statistics.vue
Normal file
@ -0,0 +1,290 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- 时段选择 -->
|
||||
<view class="range-bar">
|
||||
<view v-for="r in RANGES" :key="r.key"
|
||||
:class="['range-chip', range === r.key ? 'range-chip-active' : '']"
|
||||
@tap="selectRange(r.key)">{{ r.label }}</view>
|
||||
</view>
|
||||
|
||||
<!-- 自定义时段:起止日期 -->
|
||||
<view v-if="range === 'custom'" class="custom-row">
|
||||
<picker mode="date" :value="customStart || todayStr" :end="todayStr" @change="onCustomStart">
|
||||
<view class="date-pill">{{ customStart || '开始日期' }}</view>
|
||||
</picker>
|
||||
<text class="date-sep">至</text>
|
||||
<picker mode="date" :value="customEnd || todayStr" :start="customStart" :end="todayStr" @change="onCustomEnd">
|
||||
<view class="date-pill">{{ customEnd || todayStr }}</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view v-if="!rangeInvalid" class="range-label">{{ rangeLabel }}</view>
|
||||
|
||||
<view v-if="loading" class="center">
|
||||
<text class="center-icon">⏳</text>
|
||||
<text class="center-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<!-- 失败也要给出口:工人不会去看控制台,只有「重试」按钮能救他 -->
|
||||
<view v-else-if="error" class="error-box">
|
||||
<text class="error-text">{{ error }}</text>
|
||||
<button class="retry-btn" @tap="load">重新加载</button>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<!-- 生产战绩 -->
|
||||
<view class="card">
|
||||
<view class="card-head">
|
||||
<text class="card-title">生产战绩</text>
|
||||
<text class="card-date">{{ rangeLabel }}</text>
|
||||
</view>
|
||||
<view class="stat-row">
|
||||
<view class="stat">
|
||||
<text class="stat-num num-done">{{ stats.tasks_completed }}</text>
|
||||
<text class="stat-label">完成</text>
|
||||
</view>
|
||||
<view class="stat-divider" />
|
||||
<view class="stat">
|
||||
<text class="stat-num" :class="stats.tasks_rejected > 0 ? 'num-reject' : 'num-zero'">
|
||||
{{ stats.tasks_rejected }}
|
||||
</text>
|
||||
<text class="stat-label">被驳回</text>
|
||||
</view>
|
||||
<view class="stat-divider" />
|
||||
<view class="stat">
|
||||
<text class="stat-num num-product">{{ stats.products_touched }}</text>
|
||||
<text class="stat-label">参与产品</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="card-foot">{{ recordComment }}</view>
|
||||
</view>
|
||||
|
||||
<!-- 操作统计 —— 与 PC「人员操作统计」同一口径,方便和主管核对 -->
|
||||
<view class="card">
|
||||
<view class="card-head">
|
||||
<text class="card-title">操作统计</text>
|
||||
<text class="card-date">与管理系统同口径</text>
|
||||
</view>
|
||||
<view class="stat-row">
|
||||
<view class="stat">
|
||||
<text class="stat-num num-op">{{ stats.receive_count }}</text>
|
||||
<text class="stat-label">接收</text>
|
||||
</view>
|
||||
<view class="stat-divider" />
|
||||
<view class="stat">
|
||||
<text class="stat-num num-op">{{ stats.transfer_count }}</text>
|
||||
<text class="stat-label">转交</text>
|
||||
</view>
|
||||
<view class="stat-divider" />
|
||||
<view class="stat">
|
||||
<text class="stat-num num-op">{{ stats.record_count }}</text>
|
||||
<text class="stat-label">上传备注</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="card-foot">操作总计 {{ stats.op_total }} 次</view>
|
||||
</view>
|
||||
|
||||
<view class="hint">
|
||||
统计口径:完成 / 被驳回按本人名下任务计;接收、转交按实际操作人计,上传备注归到任务负责人名下。时间以北京时间为准。
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { onShow, onPullDownRefresh } from "@dcloudio/uni-app";
|
||||
import { getMyStats } from "../../api/stats";
|
||||
|
||||
const RANGES = [
|
||||
{ key: "today", label: "今日" },
|
||||
{ key: "week", label: "本周" },
|
||||
{ key: "month", label: "本月" },
|
||||
{ key: "custom", label: "自定义" },
|
||||
];
|
||||
|
||||
const range = ref("today");
|
||||
const customStart = ref("");
|
||||
const customEnd = ref("");
|
||||
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
const stats = ref({
|
||||
tasks_completed: 0, tasks_rejected: 0, products_touched: 0,
|
||||
receive_count: 0, transfer_count: 0, record_count: 0, op_total: 0,
|
||||
});
|
||||
|
||||
function pad(n) {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
function ymd(d) {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
const todayStr = ymd(new Date());
|
||||
|
||||
/**
|
||||
* 把当前选择换算成时间段。
|
||||
*
|
||||
* 边界一律显式拼 "+08:00" —— 车间按北京时间作息,统计口径也定义在北京时间上。
|
||||
* 若图省事发不带偏移的裸字符串,后端会按服务器本地时间解释,边界整体偏 8 小时,
|
||||
* 出现「选了今日,昨晚下午的活也算进来」这种错位。
|
||||
*/
|
||||
function buildRange() {
|
||||
const now = new Date();
|
||||
let startYmd = todayStr;
|
||||
let endYmd = todayStr;
|
||||
|
||||
if (range.value === "week") {
|
||||
const d = new Date(now);
|
||||
d.setDate(d.getDate() - ((d.getDay() + 6) % 7)); // 周一为一周之始
|
||||
startYmd = ymd(d);
|
||||
} else if (range.value === "month") {
|
||||
startYmd = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-01`;
|
||||
} else if (range.value === "custom") {
|
||||
startYmd = customStart.value || todayStr;
|
||||
endYmd = customEnd.value || todayStr;
|
||||
}
|
||||
|
||||
return {
|
||||
since: `${startYmd}T00:00:00+08:00`,
|
||||
until: `${endYmd}T23:59:59+08:00`,
|
||||
label: startYmd === endYmd ? startYmd : `${startYmd} ~ ${endYmd}`,
|
||||
};
|
||||
}
|
||||
|
||||
const rangeInvalid = computed(
|
||||
() => range.value === "custom" && !!customStart.value && !!customEnd.value && customStart.value > customEnd.value
|
||||
);
|
||||
const rangeLabel = computed(() => buildRange().label);
|
||||
|
||||
const recordComment = computed(() => {
|
||||
const { tasks_completed, tasks_rejected, products_touched } = stats.value;
|
||||
if (tasks_completed === 0 && tasks_rejected === 0) return "这段时间还没有完工记录";
|
||||
if (tasks_rejected > 0) return `有 ${tasks_rejected} 单被驳回,记得跟进返工`;
|
||||
return `完成 ${tasks_completed} 单,涉及 ${products_touched} 台设备 👍`;
|
||||
});
|
||||
|
||||
function readUsername() {
|
||||
try {
|
||||
let user = uni.getStorageSync("user");
|
||||
if (typeof user === "string" && user) user = JSON.parse(user);
|
||||
return (user && user.username) || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (rangeInvalid.value) {
|
||||
loading.value = false;
|
||||
error.value = "";
|
||||
return;
|
||||
}
|
||||
const username = readUsername();
|
||||
if (!username) {
|
||||
loading.value = false;
|
||||
error.value = "未获取到登录信息,请重新登录";
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const { since, until } = buildRange();
|
||||
const res = await getMyStats(username, since, until);
|
||||
stats.value = {
|
||||
tasks_completed: res?.tasks_completed || 0,
|
||||
tasks_rejected: res?.tasks_rejected || 0,
|
||||
products_touched: res?.products_touched || 0,
|
||||
receive_count: res?.receive_count || 0,
|
||||
transfer_count: res?.transfer_count || 0,
|
||||
record_count: res?.record_count || 0,
|
||||
op_total: res?.op_total || 0,
|
||||
};
|
||||
} catch (e) {
|
||||
// request.js 已经弹过一次 toast,这里只在页面上留一个可操作的出口
|
||||
error.value = e && e.isNetworkError ? "网络连接失败,请检查网络后重试" : "统计数据加载失败";
|
||||
console.error("[statistics] 加载个人统计失败:", e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectRange(key) {
|
||||
if (range.value === key) return;
|
||||
range.value = key;
|
||||
// 切到自定义时先给个默认区间(本月至今),避免空值直接发请求
|
||||
if (key === "custom" && !customStart.value) {
|
||||
const now = new Date();
|
||||
customStart.value = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-01`;
|
||||
customEnd.value = todayStr;
|
||||
}
|
||||
load();
|
||||
}
|
||||
|
||||
function onCustomStart(e) {
|
||||
customStart.value = e.detail.value;
|
||||
// 开始日期越过结束日期时顺势把结束日跟过去,避免出现无效区间
|
||||
if (customEnd.value && customStart.value > customEnd.value) customEnd.value = customStart.value;
|
||||
load();
|
||||
}
|
||||
function onCustomEnd(e) {
|
||||
customEnd.value = e.detail.value;
|
||||
load();
|
||||
}
|
||||
|
||||
// 统计是「每次进来都要新鲜」的数据,用 onShow 保证从详情页返回后也刷新
|
||||
onShow(load);
|
||||
|
||||
onPullDownRefresh(async () => {
|
||||
try {
|
||||
await load();
|
||||
} finally {
|
||||
uni.stopPullDownRefresh(); // 无论成败都要收起动画
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { min-height: 100vh; padding: 16px; background: #f3f4f6; }
|
||||
|
||||
/* 时段选择 */
|
||||
.range-bar { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||
.range-chip { flex: 1; height: 34px; line-height: 34px; text-align: center; border-radius: 10px; background: #fff; color: #6b7280; font-size: 13px; font-weight: 600; }
|
||||
.range-chip-active { background: #2563eb; color: #fff; }
|
||||
|
||||
/* 自定义起止日期 */
|
||||
.custom-row { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
||||
.date-pill { flex: 1; height: 36px; line-height: 36px; text-align: center; border-radius: 10px; background: #fff; color: #374151; font-size: 13px; border: 1px solid #e5e7eb; }
|
||||
.date-sep { font-size: 12px; color: #9ca3af; }
|
||||
|
||||
.range-label { font-size: 11px; color: #9ca3af; margin: 0 2px 10px; }
|
||||
|
||||
.center { display: flex; flex-direction: column; align-items: center; padding-top: 60px; }
|
||||
.center-icon { font-size: 40px; margin-bottom: 8px; }
|
||||
.center-text { font-size: 14px; color: #9ca3af; }
|
||||
|
||||
.error-box { background: #fef2f2; border: 1px solid #fecaca; border-radius: 12px; padding: 20px 16px; display: flex; flex-direction: column; align-items: center; }
|
||||
.error-text { font-size: 14px; color: #dc2626; margin-bottom: 14px; text-align: center; }
|
||||
.retry-btn { width: 160px; height: 40px; line-height: 40px; background: #2563eb; color: #fff; border-radius: 10px; font-size: 14px; font-weight: 600; padding: 0; margin: 0; }
|
||||
.retry-btn::after { border: none; }
|
||||
|
||||
.card { background: #fff; border-radius: 14px; padding: 16px; margin-bottom: 14px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
|
||||
.card-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 14px; }
|
||||
.card-title { font-size: 16px; font-weight: 700; color: #1f2937; }
|
||||
.card-date { font-size: 11px; color: #9ca3af; }
|
||||
|
||||
.stat-row { display: flex; align-items: center; }
|
||||
.stat { flex: 1; display: flex; flex-direction: column; align-items: center; }
|
||||
.stat-divider { width: 1px; height: 40px; background: #f3f4f6; }
|
||||
.stat-num { font-size: 30px; font-weight: 800; line-height: 1.15; }
|
||||
.stat-label { font-size: 12px; color: #6b7280; margin-top: 4px; }
|
||||
.num-done { color: #16a34a; }
|
||||
.num-reject { color: #dc2626; }
|
||||
.num-zero { color: #d1d5db; }
|
||||
.num-product { color: #2563eb; }
|
||||
.num-op { color: #4f46e5; }
|
||||
|
||||
.card-foot { margin-top: 14px; padding-top: 12px; border-top: 1px solid #f3f4f6; font-size: 12px; color: #9ca3af; }
|
||||
|
||||
.hint { margin-top: 4px; padding: 0 4px; font-size: 11px; color: #b0b6bf; line-height: 1.6; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user