From ec6c3fc806e733548af507fce144af17234230a8 Mon Sep 17 00:00:00 2001 From: shijing Date: Tue, 23 Jun 2026 13:43:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E5=A4=A7=E5=B1=8F=E5=B7=A5=E5=8E=82?= =?UTF-8?q?=E5=90=8D=E4=BF=AE=E6=AD=A3=E4=B8=8E=E6=97=A0=E6=84=9F=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hfnf大屏工厂ABCD改为真实名称,顶部卡片去除点击跳转,浏览器后退跳数据模板页 - request.js修复无感刷新:刷新成功后正确重试(原代码未return导致重试被丢弃)、刷新失败复位isRefreshing、并发401用token比对防刷新风暴、token清空时静默拒绝避免误报无权限 - main.js修复ResizeObserver在元素卸载时getComputedStyle非Element报错(try/catch包到debounce内层) - systemRouter新增hfnf_mplogx/cement_data_template固定子路由 - hfnf接口改走factory_api反向代理,新增stats统计接口 - layout修复pmenu.children为空时的报错 Co-Authored-By: Claude --- .env.development | 7 ++- src/api/model/hfnf.js | 15 +++++- src/layout/index.vue | 4 +- src/main.js | 11 +++- src/router/systemRouter.js | 16 +++++- src/utils/request.js | 91 ++++++++++++++++++++++++-------- src/views/fac_cal/hfnf_index.vue | 54 +++++++++++-------- 7 files changed, 148 insertions(+), 50 deletions(-) diff --git a/.env.development b/.env.development index 88d1f4fa..bf2585f4 100644 --- a/.env.development +++ b/.env.development @@ -21,8 +21,11 @@ VUE_APP_PJ = 'gx' # VUE_APP_BASEURL = http://10.50.211.228:2250/ -VUE_APP_API_BASEURL = http://127.0.0.1:2226/api -VUE_APP_BASEURL = http://127.0.0.1:2226 +# VUE_APP_API_BASEURL = http://127.0.0.1:2226/api +# VUE_APP_BASEURL = http://127.0.0.1:2226 + +VUE_APP_API_BASEURL = http://10.0.11.51:2250/api +VUE_APP_BASEURL = http://10.0.11.51:2250 # 托克逊 # VUE_APP_API_BASEURL = http://10.50.211.228:2250/api diff --git a/src/api/model/hfnf.js b/src/api/model/hfnf.js index 1e12772b..2343af9e 100644 --- a/src/api/model/hfnf.js +++ b/src/api/model/hfnf.js @@ -6,11 +6,24 @@ export default { name: "列表", req: async function(data){ return await http.post( - `${config.HOST_URL}/hfnf_api/mplogx/`, + // 同源路径,由 nginx(/factory_api/) 反向代理到统计服务 10.0.11.52:5800 + `${config.HOST_URL}/factory_api/mplogx/`, // "http://10.0.11.52:5800/mplogx/", data ); } }, + }, + stats: { + list: { + name: "数据统计", + req: async function(params){ + return await http.get( + // 同源路径,由 nginx(/factory_api/) 反向代理到统计服务 10.0.11.52:5800 + `${config.HOST_URL}/factory_api/stats`, + params + ); + } + }, } } \ No newline at end of file diff --git a/src/layout/index.vue b/src/layout/index.vue index 298d3346..803f0db8 100644 --- a/src/layout/index.vue +++ b/src/layout/index.vue @@ -414,8 +414,8 @@ export default { } this.pmenu = this.$route.meta.breadcrumb ? this.$route.meta.breadcrumb[0] - : {}; - let lists = this.pmenu.children; + : { meta: {}, children: [] }; + let lists = this.pmenu.children || []; let perms = this.$TOOL.data.get("PERMISSIONS") || []; for (var i = 0; i < lists.length; i++) { if (lists[i].meta && lists[i].meta.perms) { diff --git a/src/main.js b/src/main.js index 01fb4084..0e07e532 100644 --- a/src/main.js +++ b/src/main.js @@ -55,7 +55,14 @@ const _ResizeObserver = window.ResizeObserver; window.ResizeObserver = class ResizeObserver extends _ResizeObserver { constructor(callback) { - callback = debounce(callback, 16); - super(callback); + // 先包 try/catch 再 debounce:debounce 用 setTimeout 延迟执行, + // 必须让真正执行回调时也在 try/catch 内,否则元素卸载瞬间 + // getComputedStyle 拿到非 Element 的错误会逃出 setTimeout 成为未捕获异常 + const safe = (entries, observer) => { + try { + callback(entries, observer); + } catch (e) {} + }; + super(debounce(safe, 16)); } }; diff --git a/src/router/systemRouter.js b/src/router/systemRouter.js index ae775add..847ff04e 100644 --- a/src/router/systemRouter.js +++ b/src/router/systemRouter.js @@ -7,7 +7,21 @@ const routes = [ path: "/", component: () => import(/* webpackChunkName: "layout" */ '@/layout'), redirect: config.DASHBOARD_URL || getBaseDashboard(), - children: [] + //固定页:通过按钮跳转、不依赖菜单(动态/静态)的页面 + children: [ + { + name: "hfnf_mplogx", + path: "/hfnf_mplogx", + meta: { title: "测点数据明细", hidden: true }, + component: () => import('@/views/fac_cal/hfnf_mplogx'), + }, + { + name: "cement_data_template", + path: "/cement_data_template", + meta: { title: "数据模板", hidden: true }, + component: () => import('@/views/fac_cal/cement_data_template'), + }, + ] }, { path: "/login", diff --git a/src/utils/request.js b/src/utils/request.js index 23ade776..fbedd976 100644 --- a/src/utils/request.js +++ b/src/utils/request.js @@ -18,6 +18,8 @@ axios.interceptors.request.use( let token = tool.data.get("TOKEN"); if(token){ config.headers[sysConfig.TOKEN_NAME] = sysConfig.TOKEN_PREFIX + token + // 记录本次请求实际使用的 token,用于 401 时判断是否已被并发刷新过 + config._tokenUsed = token; } if(!sysConfig.REQUEST_CACHE && config.method == 'get'){ config.params = config.params || {}; @@ -49,39 +51,86 @@ axios.interceptors.response.use( }); }else{ if (error.config.url.indexOf('auth/token/refresh/') != -1) { - isRefreshing = false; - requests=[]; + // 刷新接口自身 401:refresh token 已失效,清除登录态并跳登录 + isRefreshing = false; + requests = []; + tool.data.remove("TOKEN"); + tool.data.remove("TOKEN_REFRESH"); + console.warn('[auth] refresh token 失效,跳转登录页'); router.push('/login'); - return + return Promise.reject(error.response); + } + const currentToken = tool.data.get("TOKEN"); + // 已无 token(refresh 失败已清登录态并跳登录):并发请求直接拒绝, + // 不再重试/刷新,避免误报“无权限” + if (!currentToken) { + return Promise.reject(error.response); + } + // 本次请求用的 token 已和当前 localStorage 里的不一致, + // 说明期间已被并发刷新过,直接用最新 token 重试一次即可,不再触发刷新 + // (避免快速点击时多个晚到 401 引发刷新风暴) + if (error.config._tokenUsed && error.config._tokenUsed !== currentToken && !error.config._retriedAfterRefresh) { + error.config._tokenUsed = currentToken; + error.config._retriedAfterRefresh = true; + return axios(error.config); + } + // 已刷新过 token 仍然 401:说明不是 token 过期,而是无权限/持久 401。 + // 不再刷新、不跳登录,只提示无权限,避免刷新循环把会话耗到失效。 + if (error.config._retriedAfterRefresh) { + console.warn('[auth] 刷新后仍 401,判定为无权限,不跳登录', error.config.url); + ElNotification.warning({ + title: '无权限', + message: (error.response.data && error.response.data.err_msg) || '当前账号无权限访问该资源' + }); + return Promise.reject(error.response); } if (!isRefreshing) { isRefreshing = true; - axios({ - method: 'post', - url: sysConfig.API_URL+'/auth/token/refresh/', - headers: { - Authorization: `Bearer ${tool.data.get("TOKEN")}`, - }, - data:{refresh:tool.data.get("TOKEN_REFRESH")} - }).then(res=>{ - // 替换本地的token - tool.data.set("TOKEN", res.data.access); - tool.data.set("TOKEN_TIME", new Date().getTime()); - error.config.headers[sysConfig.TOKEN_NAME] = sysConfig.TOKEN_PREFIX + res.data.access; - isRefreshing = false; - requests.forEach(cb => cb()) - //发起后将requests置空 - requests=[]; - return axios(error.config) + // 必须 return 刷新 Promise,否则即便刷新成功,原始请求也会被 reject,重试结果被丢弃 + return new Promise((resolve, reject) => { + axios({ + method: 'post', + url: sysConfig.API_URL+'/auth/token/refresh/', + headers: { + Authorization: `Bearer ${tool.data.get("TOKEN")}`, + }, + data:{refresh:tool.data.get("TOKEN_REFRESH")} + }).then(res=>{ + // 替换本地的token + tool.data.set("TOKEN", res.data.access); + tool.data.set("TOKEN_TIME", new Date().getTime()); + // 若后端开启了 refresh token 轮换,需同步更新本地 refresh token, + // 否则旧的被拉黑后,下一次刷新就会 401 → 跳登录(admin 也会偶发被踢) + if (res.data && res.data.refresh) { + tool.data.set("TOKEN_REFRESH", res.data.refresh); + } + error.config.headers[sysConfig.TOKEN_NAME] = sysConfig.TOKEN_PREFIX + res.data.access; + error.config._retriedAfterRefresh = true; // 标记:重试后若仍 401 则视为无权限 + isRefreshing = false; + //发起后将requests置空 + const queue = requests; requests = []; + queue.forEach(cb => cb()) + resolve(axios(error.config)) + }).catch(err=>{ + // 刷新失败:复位状态,清登录态,跳登录 + isRefreshing = false; + requests = []; + tool.data.remove("TOKEN"); + tool.data.remove("TOKEN_REFRESH"); + console.warn('[auth] token 刷新失败,跳转登录页', err); + router.push('/login'); + reject(err); + }) }) }else{ //如果正在刷新token,将发起的请求暂存在requests中 return new Promise((resolve)=>{ requests.push(()=>{ + error.config._retriedAfterRefresh = true; resolve(axios(error.config)); }) }) - } + } } } else { diff --git a/src/views/fac_cal/hfnf_index.vue b/src/views/fac_cal/hfnf_index.vue index 7f08dc23..2e051bce 100644 --- a/src/views/fac_cal/hfnf_index.vue +++ b/src/views/fac_cal/hfnf_index.vue @@ -23,7 +23,7 @@ -
+
@@ -193,30 +193,32 @@ let pieChart = null const colors = ['#409eff', '#67c23a', '#e6a23c', '#f56c6c'] const bgColors = ['#ecf5ff', '#f0f9eb', '#fdf6ec', '#fef0f0'] -// 各工厂基础比例 -const baseFactories = [ - { name: '合肥南方', ratio: 0.4382 }, - { name: '中联万吨', ratio: 0.3081 }, - { name: '铜梁水泥', ratio: 0.1424 }, - { name: '槐坎南方水泥', ratio: 0.1113 }, +// 各工厂与数据表的映射关系 +const factoryDefs = [ + { name: '合肥南方', match: (t) => t === 'mplogx' }, // 合肥南方 + { name: '中联万吨', match: (t) => t === 'mplogx_xzzl' }, // 中联万吨 + { name: '铜梁水泥', match: (t) => t === 'mplogx_tlxn' }, // 铜梁水泥 + { name: '槐坎南方水泥', match: (t) => t.startsWith('mplogx_hknf') }, // 槁坎南方水泥 ] const totalCount = ref(0) +const tables = ref([]) const factories = computed(() => { const total = totalCount.value - // 前3个按比例取整,最后一个用总量减去前3个保证总和精确 - const counts = baseFactories.map(f => Math.round(total * f.ratio)) - const sumFirst3 = counts[0] + counts[1] + counts[2] - counts[3] = total - sumFirst3 - - return baseFactories.map((f, i) => ({ - name: f.name, - count: counts[i], - color: colors[i], - bgColor: bgColors[i], - percentage: total > 0 ? (counts[i] / total) * 100 : 0, - })) + return factoryDefs.map((f, i) => { + // 同一工厂可能对应多张数据表,按表名匹配后累加行数 + const count = tables.value + .filter((t) => f.match(t.table_name)) + .reduce((sum, t) => sum + (t.approximate_row_count || 0), 0) + return { + name: f.name, + count, + color: colors[i], + bgColor: bgColors[i], + percentage: total > 0 ? (count / total) * 100 : 0, + } + }) }) const maxFactory = computed(() => { @@ -336,19 +338,29 @@ const handleResize = () => { pieChart?.resize() } +// 拦截浏览器后退:在大屏页按“返回”时跳转到数据模板页 +const handlePopState = () => { + router.push('/cement_data_template') +} + onMounted(() => { - API.hfnf.mplogx.list.req({ page: 1, page_size: 1 }).then(res => { - totalCount.value = res.count + API.hfnf.stats.list.req().then(res => { + tables.value = res.tables || [] + totalCount.value = res.summary?.approximate_row_count_total || 0 nextTick(() => { initBarChart() initPieChart() }) }) window.addEventListener('resize', handleResize) + // 多压一条相同的历史记录,使浏览器后退可被 popstate 拦截 + history.pushState(null, '', location.href) + window.addEventListener('popstate', handlePopState) }) onBeforeUnmount(() => { window.removeEventListener('resize', handleResize) + window.removeEventListener('popstate', handlePopState) barChart?.dispose() pieChart?.dispose() })