Files
track/track-uniapp/src/pages/profile/settings.vue
duxingchen df3f914eb1 fix(audit): 修复「退出登录」无日志(PC + 移动端)
现象:审计日志里 logout 记录数恒为 0,退出动作完全不可见。

根因有两处,缺一不可:

1. 移动端(App)根本没有发起过上报
   settings.vue 的 handleLogout 只做 removeStorageSync + reLaunch,
   一个请求都没发。而 uni.reLaunch 会销毁页面上下文、直接掐断未完成的
   uni.request —— 所以必须「先 await 上报、再清 token 与跳转」。

2. PC 端存在时序竞态
   axios 的请求拦截器在微任务里执行、现读 localStorage 取 token;
   而原实现同步清空 localStorage 并立刻 navigate,拦截器跑到时 token
   已经没了 → 请求不带 Authorization → 后端只能记成「未认证」。
   注:只改 AuthContext 不够,调用方 AdminLayout / ProfilePage 原来是
   `logout(); navigate(...)`,不等就跳转照样会掐断请求 —— 三处都得改。

改动:
- 移动端 handleLogout 改 async,await post("/auth/logout") 后再清 token
- AuthContext.logout 改 async(类型同步为 () => Promise<void>),
  先 await 上报再清 token;失败静默,绝不阻断退出
- AdminLayout / ProfilePage 两个调用点补 await
- 全部用 try-catch 兜住:断网/超时也照退不误(JWT 无状态,
  服务端本就不需要它成功)
2026-09-21 13:47:31 +08:00

232 lines
9.1 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="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";
import { post } from "../../utils/request";
// ============================================================
// 缓存清理策略 —— 黑名单式:只删「明确登记过的业务缓存」
// ============================================================
//
// 不用白名单(遍历时跳过白名单、其余全删):那样每引入一个新的插件 / 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 });
}
async function handleLogout() {
// 退出是不可逆的(要重新输账号密码),按车间使用场景加一道确认防误触
uni.showModal({
title: "退出登录",
content: "退出后需要重新输入账号密码,确定退出吗?",
confirmText: "退出",
cancelText: "取消",
success: async (res) => {
if (!res.confirm) return;
// 🔴 必须【先 await 上报、再清 token】——两者顺序反了或不等,退出就留不下痕:
// 1) uni.reLaunch 会销毁页面上下文,直接掐断尚未发出的 uni.request;
// 2) 而 request.js 是在发送时才从 storage 读 access_token,
// 先清 storage 的话请求会不带 Authorization,后端只能记成「未认证」。
// 这里刻意 try/catch 兜住:上报失败(断网/超时)也绝不能挡住用户退出。
uni.showLoading({ title: "退出中...", mask: true });
try {
await post("/auth/logout");
} catch (e) {
// 静默:JWT 无状态,服务端本就不需要它成功
console.warn("[logout] 上报失败(不影响退出)", e);
} finally {
uni.hideLoading();
}
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>