现象:审计日志里 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 无状态,
服务端本就不需要它成功)
232 lines
9.1 KiB
Vue
232 lines
9.1 KiB
Vue
<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>
|