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:
2026-09-15 15:51:50 +08:00
parent 7fca315ff4
commit c7f046a993
7 changed files with 850 additions and 26 deletions

View 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 {
// silent404 会走到下面的 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;
}
}

View 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 });
}

View File

@ -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": {

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

View File

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

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

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