From f17f6153b55602806600da9407f98027ca66c29c Mon Sep 17 00:00:00 2001 From: hongqiwei <1143662660@qq.com> Date: Tue, 27 Oct 2026 19:44:55 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=81=E4=B8=9A=E5=BE=AE=E4=BF=A1=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E5=92=8Coa=E5=BE=85=E5=8A=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/impl/SchoolOaTodoServiceImpl.java | 110 ++++++++++++++++-- .../app/web/commons/proc/WkFailProcessor.java | 76 +++++++++--- .../views/layouts/v4/baseLayout.html | 23 +++- .../difficulthelp/apply/index.html | 1 - .../condolence/schoolUnionApproval/index.html | 2 +- .../branchunionapproval/index.html | 35 ++++-- .../schoolunionapproval/index.html | 2 +- .../unionLeaderApproval/index.html | 2 +- .../views/platform/zhghh5/sys/home/apps.js | 14 ++- .../views/platform/zhghh5/sys/home/index.html | 12 +- .../views/platform/zhghh5/sys/home/mine.js | 15 ++- 11 files changed, 238 insertions(+), 54 deletions(-) diff --git a/src/main/java/com/budwk/app/flow/service/impl/SchoolOaTodoServiceImpl.java b/src/main/java/com/budwk/app/flow/service/impl/SchoolOaTodoServiceImpl.java index 7baa136..398fd3a 100644 --- a/src/main/java/com/budwk/app/flow/service/impl/SchoolOaTodoServiceImpl.java +++ b/src/main/java/com/budwk/app/flow/service/impl/SchoolOaTodoServiceImpl.java @@ -30,6 +30,7 @@ import java.util.Base64; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.IdentityHashMap; import java.util.stream.Collectors; /** @@ -138,8 +139,10 @@ public class SchoolOaTodoServiceImpl implements SchoolOaTodoService { JSONObject result = execute(HttpRequest.get(endpoint).header("Authorization", "Bearer " + getAccessToken()), "待办同步"); Integer errorCode = result.getInt("errcode"); if (errorCode == null || errorCode != 0) { - // 远端消息可能回显密钥或请求地址,不把原始响应直接输出到日志和页面。 - throw new BaseException("通达OA待办同步失败:errcode={},taskId={}", errorCode, task.getId()); + String detail = responseError(result); + log.error("通达OA待办业务失败,taskId={},instanceId={},status={},errcode={},detail={}", + task.getId(), task.getProcessInstanceId(), status, errorCode, detail); + throw new BaseException("通达OA待办同步失败:errcode={},taskId={},{}", errorCode, task.getId(), detail); } log.info("通达OA接受待办同步请求,taskId={},status={}", task.getId(), status); } @@ -188,44 +191,129 @@ public class SchoolOaTodoServiceImpl implements SchoolOaTodoService { String password = conf.get(PREFIX + "token-password", "password"); String currentConfig = JSONUtil.toJsonStr(List.of(base, clientId, secret, username, password)); long now = System.currentTimeMillis(); - if (currentConfig.equals(tokenConfig) && StrUtil.isNotBlank(accessToken) && now < tokenExpiresAt) return accessToken; + if (currentConfig.equals(tokenConfig) && StrUtil.isNotBlank(accessToken) && now < tokenExpiresAt) { + // 只记录缓存剩余有效期,便于区分复用与重新获取,不输出凭据。 + log.info("通达OA复用token缓存,remainingSeconds={}", (tokenExpiresAt - now) / 1000L); + return accessToken; + } Map form = new LinkedHashMap<>(); form.put("grant_type", "password"); form.put("client_id", clientId); form.put("client_secret", secret); form.put("username", username); form.put("password", password); + long startedAt = System.nanoTime(); JSONObject result = execute(HttpRequest.post(base + "/oauth2/token") .header("Content-Type", "application/x-www-form-urlencoded") .body(encodeParams(form)), "获取凭据"); String token = result.getStr("access_token"); Long expires = result.getLong("expires_in"); if (StrUtil.isBlank(token) || expires == null || expires <= 0 || expires > Integer.MAX_VALUE) { - throw new BaseException("通达OA获取凭据失败:缺少有效的 access_token 或 expires_in"); + String detail = responseError(result); + log.error("通达OA获取凭据业务失败,缺少有效的 access_token 或 expires_in,detail={}", detail); + throw new BaseException("通达OA获取凭据失败:缺少有效的 access_token 或 expires_in,{}", detail); } accessToken = token; tokenConfig = currentConfig; tokenExpiresAt = now + (expires - Math.min(60, expires / 10)) * 1000L; + log.info("通达OA获取token成功,elapsedMs={},expiresInSeconds={}", + (System.nanoTime() - startedAt) / 1_000_000, expires); return token; } /** HTTP 成功不代表业务成功;本方法只验证状态码和 JSON,业务码由调用处校验。 */ private JSONObject execute(HttpRequest request, String action) { - try (HttpResponse response = request.timeout(conf.getInt(PREFIX + "timeout-ms", 10000)).execute()) { + long startedAt = System.nanoTime(); + int timeout = conf.getInt(PREFIX + "timeout-ms", 10000); + // 只记录接口地址,不记录 GET 查询参数、请求头或 token 请求正文。 + String endpoint = request.getUrl().split("[?#]", 2)[0]; + try (HttpResponse response = request.timeout(timeout).execute()) { + String body = response.body(); + boolean jsonObject = JSONUtil.isTypeJSONObject(body); + // 非 JSON 错误常为 OA 或网关的 HTML 页面;仅在日志保留脱敏摘要,不把页面返回给前端。 + if (!jsonObject) { + log.error("通达OA非JSON响应,action={},method={},endpoint={},httpStatus={},contentType={},bodySummary={}", + action, request.getMethod(), endpoint, response.getStatus(), + redact(response.header("Content-Type")), responseBodySummary(body)); + } if (!response.isOk()) { if (response.getStatus() == 401) invalidateToken(); - throw new BaseException("通达OA{}失败:HTTP {}", action, response.getStatus()); + String detail = jsonObject + ? responseError(JSONUtil.parseObj(body)) : "响应不是JSON对象,脱敏响应摘要请查看服务端日志"; + throw new BaseException("通达OA{}失败:HTTP {},{}", action, response.getStatus(), detail); } - if (!JSONUtil.isTypeJSONObject(response.body())) throw new BaseException("通达OA{}失败:响应不是JSON对象", action); - return JSONUtil.parseObj(response.body()); - } catch (BaseException e) { - throw e; + if (!jsonObject) throw new BaseException("通达OA{}失败:响应不是JSON对象,脱敏响应摘要请查看服务端日志", action); + return JSONUtil.parseObj(body); } catch (Exception e) { + long elapsed = (System.nanoTime() - startedAt) / 1_000_000; + Throwable root = e; + Map visited = new IdentityHashMap<>(); + while (root.getCause() != null && visited.put(root, Boolean.TRUE) == null + && !visited.containsKey(root.getCause())) root = root.getCause(); + String detail = root.getClass().getName() + ": " + redact(root.getMessage()); + // 保留各层原因、原始栈帧及 suppressed 异常;先复制脱敏,避免全局异常处理器泄露凭证。 + Throwable safeCause = diagnosticCause(e, new IdentityHashMap<>()); + log.error("通达OA请求失败,action={},method={},endpoint={},elapsedMs={},timeoutMs={},rootCause={}", + action, request.getMethod(), endpoint, elapsed, timeout, detail, safeCause); // 不自动重放写请求,超时可能发生在远端已成功之后;避免重复待办。 - throw new BaseException("通达OA{}异常({}),请核对远端结果后再重试", action, e.getClass().getSimpleName()); + BaseException failure = new BaseException("通达OA{}失败,耗时{}ms:{};请核对远端结果后再重试", action, elapsed, detail); + failure.initCause(safeCause); + throw failure; } } + /** body 为远端非 JSON 正文;先脱敏再截取最多 2000 字符,避免截断凭据后无法匹配脱敏。 */ + private String responseBodySummary(String body) { + if (StrUtil.isBlank(body)) return "[空响应正文]"; + String safe = redact(body); + return safe.length() > 2000 ? safe.substring(0, 2000) + "[已截断]" : safe; + } + + /** 只提取接口错误字段,不输出含 access_token、refresh_token 等内容的整个响应。 */ + private String responseError(JSONObject response) { + Map errors = new LinkedHashMap<>(); + for (String key : List.of("error", "error_description", "errcode", "errmsg", "message")) { + if (response.containsKey(key)) errors.put(key, redact(String.valueOf(response.get(key)))); + } + return errors.isEmpty() ? "OA未返回错误说明" : JSONUtil.toJsonStr(errors); + } + + /** + * 复制异常诊断信息,message 标注原异常类型,保留原始栈帧和原因链。 + * visited 防止第三方异常存在循环引用;原异常不修改,也不直接写入日志。 + */ + private Throwable diagnosticCause(Throwable source, Map visited) { + if (visited.containsKey(source)) return visited.get(source); + RuntimeException copy = new RuntimeException(source.getClass().getName() + ": " + redact(source.getMessage())); + visited.put(source, copy); + copy.setStackTrace(source.getStackTrace()); + if (source.getCause() != null && !visited.containsKey(source.getCause())) { + copy.initCause(diagnosticCause(source.getCause(), visited)); + } + for (Throwable suppressed : source.getSuppressed()) { + if (!visited.containsKey(suppressed)) copy.addSuppressed(diagnosticCause(suppressed, visited)); + } + return copy; + } + + /** 清除已知凭证及可能被远端回显的认证字段、URL查询参数,并移除日志换行。 */ + private String redact(String message) { + if (message == null) return "无异常说明"; + String text = message; + for (String key : List.of("client-secret-encrypted", "token-password", "client-id")) { + String value = conf.get(PREFIX + key); + if (StrUtil.isNotBlank(value)) { + text = text.replace(value, "[REDACTED]") + .replace(URLEncoder.encode(value, StandardCharsets.UTF_8), "[REDACTED]"); + } + } + if (StrUtil.isNotBlank(accessToken)) text = text.replace(accessToken, "[REDACTED]"); + text = text.replaceAll("(?i)Bearer\\s+[^\\s,;\\\"']+", "Bearer [REDACTED]"); + text = text.replaceAll("(?i)((?:client_secret|access_token|refresh_token|password|authorization)[\\\"']?\\s*[:=]\\s*)[\\\"']?[^\\s&,;}\\\"']+[\\\"']?", "$1[REDACTED]"); + text = text.replaceAll("(https?://[^\\s?#]+)\\?[^\\s]+", "$1?[REDACTED]"); + return text.replace('\r', ' ').replace('\n', ' '); + } + private synchronized void invalidateToken() { accessToken = null; tokenExpiresAt = 0; diff --git a/src/main/java/com/budwk/app/web/commons/proc/WkFailProcessor.java b/src/main/java/com/budwk/app/web/commons/proc/WkFailProcessor.java index 5fa871d..90d8227 100644 --- a/src/main/java/com/budwk/app/web/commons/proc/WkFailProcessor.java +++ b/src/main/java/com/budwk/app/web/commons/proc/WkFailProcessor.java @@ -189,25 +189,16 @@ public class WkFailProcessor extends ViewProcessor { if (Globals.sso) { //1.判断是否为企业微信浏览器并且对接了企业微信 if (browserPlatform == BrowserPlatform.QI_YE_WEI_XIN && propertiesProxy.getBoolean("wxwork.enable", false)) { - //进行企业微信授权 -// String token = getWxWorkToken(); -// if (StrUtil.isNotBlank(token)) { -// //跳转到企业微信授权地址 -// new ServerRedirectView(buildWxWorkOauth2Url()).render(request, response, null); -// } else { -// //跳转到错误页 别问我为啥错了 我也不知道 首先去检查配置文件里的参数有没有写对 -//// new ServerRedirectView(Globals.AppDomain + errorUri).render(request, response, null); -// //跳转到cas登录页 -// String callBackUrl = propertiesProxy.get("cas.client-host-url") + propertiesProxy.get("cas.client-call-back-url") + "?redirect=" + redirect; -// String authUrl = propertiesProxy.get("cas.qi-wx-server-login-url") + "?service=" + URLEncoder.encode(callBackUrl, StandardCharsets.UTF_8); -// new ServerRedirectView(authUrl).render(request, response, null); -// } - - // 武汉城市职业学院 跳转到企业微信cas登录页 - String callBackUrl = propertiesProxy.get("wecom.wecom-client-host-url") + propertiesProxy.get("wecom.wecom-client-call-back-url") + "?redirect=" + redirect; - String authUrl = propertiesProxy.get("wecom.wecom-server-login-url") + "?service=" + URLEncoder.encode(callBackUrl, StandardCharsets.UTF_8); - log.info("auth:"+authUrl); - new ServerRedirectView(authUrl).render(request, response, null); + // 专用企业微信认证配置缺失时整组回退到已接通的 CAS,保留卡片原始页面。 + try { + String authUrl = buildEnterpriseCasUrl(redirect); + log.info("auth:" + authUrl); + new ServerRedirectView(authUrl).render(request, response, null); + } catch (IllegalArgumentException e) { + log.error("企业微信登录跳转配置无效:" + e.getMessage()); + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + "统一身份认证配置不完整或无效,请联系管理员检查认证地址及回调地址"); + } } else if (browserPlatform == BrowserPlatform.WEI_XIN && propertiesProxy.getBoolean("wx.enable", false)) { //进行微信公众号授权 //跳转到微信授权页 @@ -225,6 +216,53 @@ public class WkFailProcessor extends ViewProcessor { } } + /** + * 构造企业微信内的 CAS 登录地址。redirect 为已 URL 编码的原始站内路径及查询参数; + * 返回携带 service 参数的完整认证 URL,认证回调通过 redirect 返回卡片页面。 + * 三个 wecom 配置必须同时存在,否则整体使用 cas 配置,避免混用两套认证平台。 + */ + private String buildEnterpriseCasUrl(String redirect) { + String loginUrl = propertiesProxy.get("wecom.wecom-server-login-url"); + String clientHost = propertiesProxy.get("wecom.wecom-client-host-url"); + String callbackPath = propertiesProxy.get("wecom.wecom-client-call-back-url"); + if (missingAuthConfig(loginUrl) || missingAuthConfig(clientHost) || missingAuthConfig(callbackPath)) { + loginUrl = propertiesProxy.get("cas.server-login-url"); + clientHost = propertiesProxy.get("cas.client-host-url"); + callbackPath = propertiesProxy.get("cas.client-call-back-url"); + } + if (missingAuthConfig(loginUrl) || missingAuthConfig(clientHost) || missingAuthConfig(callbackPath)) { + throw new IllegalArgumentException("CAS 登录地址、系统域名或回调路径为空"); + } + loginUrl = loginUrl.trim(); + clientHost = clientHost.trim(); + callbackPath = callbackPath.trim(); + validateAuthUrl(loginUrl); + validateAuthUrl(clientHost); + // 回调必须是本系统路径,不能被绝对 URL 或双斜线路径覆盖到其他主机。 + if (!callbackPath.startsWith("/") || callbackPath.startsWith("//") || callbackPath.contains("#")) { + throw new IllegalArgumentException("认证回调必须是以单斜线开头的站内路径"); + } + String callback = clientHost.replaceAll("/+$", "") + callbackPath + + (callbackPath.contains("?") ? "&" : "?") + "redirect=" + redirect; + // 保留部署配置中的 HTTP/HTTPS 协议;工会 HTTPS 回调不会被改为 HTTP。 + return loginUrl + (loginUrl.contains("?") ? "&" : "?") + "service=" + + URLEncoder.encode(callback, StandardCharsets.UTF_8); + } + + /** 空值和字面量 null 都视为未配置,防止拼接出浏览器相对地址 null?service=...。 */ + private boolean missingAuthConfig(String value) { + return StrUtil.isBlank(value) || "null".equalsIgnoreCase(value.trim()); + } + + /** 认证地址和回调域名必须为完整 HTTP(S) URL,拒绝无主机名、片段或用户信息的地址。 */ + private void validateAuthUrl(String value) { + java.net.URI uri = java.net.URI.create(value); + if (!("https".equalsIgnoreCase(uri.getScheme()) || "http".equalsIgnoreCase(uri.getScheme())) + || uri.getHost() == null || uri.getFragment() != null || uri.getUserInfo() != null) { + throw new IllegalArgumentException("认证配置不是有效的 HTTP(S) 地址"); + } + } + /** * 判断浏览器平台 * diff --git a/src/main/resources/views/layouts/v4/baseLayout.html b/src/main/resources/views/layouts/v4/baseLayout.html index 3e68c78..368bf9a 100644 --- a/src/main/resources/views/layouts/v4/baseLayout.html +++ b/src/main/resources/views/layouts/v4/baseLayout.html @@ -482,7 +482,8 @@ align-items: center; gap: 24px; height: 100%; - max-width: 310px; + /* 用户区按完整姓名、账号及操作按钮的实际宽度布局,避免退出按钮溢出后被顶栏裁切。 */ + width: max-content; flex-shrink: 0; } @@ -618,6 +619,26 @@ height: 100%; } + /* 常见笔记本宽度下压缩导航留白,为完整用户信息与退出按钮腾出空间。 */ + @media (min-width: 1025px) and (max-width: 1440px) { + .v4-nav { + gap: 12px; + } + + .v4-nav-item { + padding: 0 8px; + column-gap: 6px; + } + + .v4-user-section { + gap: 12px; + } + + .v4-user-info { + padding: 0 6px; + } + } + @media (max-width: 1024px) { .v4-nav { gap: 24px; diff --git a/src/main/resources/views/platform/zhgh/staffbenefit/difficulthelp/apply/index.html b/src/main/resources/views/platform/zhgh/staffbenefit/difficulthelp/apply/index.html index 425be24..0767aa6 100644 --- a/src/main/resources/views/platform/zhgh/staffbenefit/difficulthelp/apply/index.html +++ b/src/main/resources/views/platform/zhgh/staffbenefit/difficulthelp/apply/index.html @@ -342,7 +342,6 @@ layout("/layouts/platform.html"){ officialCapacity: [{required: true, message: '请填写职称', trigger: ['blur', 'change']}], subsidyStandards: [{required: true, message: '请选择困难类型', trigger: ['blur', 'change']}], reason: [{required: true, message: '请填写申请原因', trigger: ['blur', 'change']}], - sign: [{required: true, message: '请签字', trigger: ['blur', 'change']}] }, // 替他人申请相关 diff --git a/src/main/resources/views/platform/zhghh5/staffbenefit/condolence/schoolUnionApproval/index.html b/src/main/resources/views/platform/zhghh5/staffbenefit/condolence/schoolUnionApproval/index.html index e42a0cf..3a9b9e6 100644 --- a/src/main/resources/views/platform/zhghh5/staffbenefit/condolence/schoolUnionApproval/index.html +++ b/src/main/resources/views/platform/zhghh5/staffbenefit/condolence/schoolUnionApproval/index.html @@ -55,7 +55,7 @@ layout("/layouts/platform_h5.html"){
- +
diff --git a/src/main/resources/views/platform/zhghh5/staffbenefit/difficulthelp/branchunionapproval/index.html b/src/main/resources/views/platform/zhghh5/staffbenefit/difficulthelp/branchunionapproval/index.html index 90be2f0..b4a72ac 100644 --- a/src/main/resources/views/platform/zhghh5/staffbenefit/difficulthelp/branchunionapproval/index.html +++ b/src/main/resources/views/platform/zhghh5/staffbenefit/difficulthelp/branchunionapproval/index.html @@ -9,7 +9,7 @@ layout("/layouts/platform_h5.html"){
- +
{ + this.$set(this, "confirmVisible", stack.includes("difficult-help-action-confirm")) + this.$set(this, "showLevelPicker", stack.includes("difficult-help-level-picker")) + })) + } this.initLevelDict() - this.initUnion().then(() => this.flushUnits()) - if (!window.h5HistoryLayerManager) return - this.$set(this, "historyLayerUnsubscribe", window.h5HistoryLayerManager.subscribe((stack) => { - this.$set(this, "confirmVisible", stack.includes("difficult-help-action-confirm")) - this.$set(this, "showLevelPicker", stack.includes("difficult-help-level-picker")) - })) + // 缓存已就绪时立即加载,否则等待用户接口回填;只初始化一次,避免重置已选筛选条件。 + // $watch 由当前 Vue 实例管理,页面销毁时自动注销。 + let filtersInitialized = false + this.$watch("filterUserReady", (ready) => { + if (!ready || filtersInitialized) return + filtersInitialized = true + this.initUnion().then(() => this.flushUnits()) + }, {immediate: true}) }, beforeDestroy() { if (typeof this.historyLayerUnsubscribe === "function") this.historyLayerUnsubscribe() diff --git a/src/main/resources/views/platform/zhghh5/staffbenefit/difficulthelp/schoolunionapproval/index.html b/src/main/resources/views/platform/zhghh5/staffbenefit/difficulthelp/schoolunionapproval/index.html index 0c1141e..f32eda7 100644 --- a/src/main/resources/views/platform/zhghh5/staffbenefit/difficulthelp/schoolunionapproval/index.html +++ b/src/main/resources/views/platform/zhghh5/staffbenefit/difficulthelp/schoolunionapproval/index.html @@ -9,7 +9,7 @@ layout("/layouts/platform_h5.html"){
- +
- +
@@ -201,7 +205,8 @@ const apps = { }, mounted() { // 动态分类加载完成后再恢复分类,避免把缓存的动态分类误判为“全部应用” - this.restoreAppsState() + // 指定入口不恢复旧搜索词、滚动位置及子菜单弹框,避免目标分类被旧状态遮挡。 + if (!this.initialCategoryName) this.restoreAppsState() this.loadCategories().finally(() => { this.restoreActiveCategory() this.loadApps() @@ -249,6 +254,13 @@ const apps = { }, restoreActiveCategory() { + // 动态分类返回后按名称定位;未配置或无可用分类时明确提示,并展示全部应用。 + if (this.initialCategoryName) { + const targetIndex = this.allCategories.findIndex(item => item.name === this.initialCategoryName) + this.$set(this, "activeTab", targetIndex >= 0 ? targetIndex : 0) + if (targetIndex < 0) this.$toast("未找到“" + this.initialCategoryName + "”分类") + return + } const index = this.allCategories.findIndex(item => String(item.id) === String(this.cachedCategoryId)) this.activeTab = index >= 0 ? index : 0 }, diff --git a/src/main/resources/views/platform/zhghh5/sys/home/index.html b/src/main/resources/views/platform/zhghh5/sys/home/index.html index 579c1ab..81f1556 100644 --- a/src/main/resources/views/platform/zhghh5/sys/home/index.html +++ b/src/main/resources/views/platform/zhghh5/sys/home/index.html @@ -73,7 +73,7 @@ layout("/layouts/platform_h5.html"){ - +
@@ -111,19 +111,25 @@ layout("/layouts/platform_h5.html"){ data() { return { homeTabbarActive: "home", + appsCategoryName: "", todoCount: 0, unreadMessageCount: 0 } }, methods: { homeTabbarChange(val) { + // 普通页签切换沿用应用页缓存,不保留工具入口指定的分类。 + this.$set(this, "appsCategoryName", "") this.$store.commit("setActiveTarBar", val) }, - switchHomeTab(val) { - this.homeTabbarActive = val + // categoryName 来自“我的工具”,先传分类再挂载应用页,避免读取上一次的分类缓存。 + switchHomeTab(val, categoryName = "") { + this.$set(this, "appsCategoryName", val === "apps" ? categoryName : "") + this.$set(this, "homeTabbarActive", val) this.$store.commit("setActiveTarBar", val) }, moduleClick(val) { + this.$set(this, "appsCategoryName", "") this.homeTabbarActive = "apps" this.$store.commit("setActiveTarBar", "apps") }, diff --git a/src/main/resources/views/platform/zhghh5/sys/home/mine.js b/src/main/resources/views/platform/zhghh5/sys/home/mine.js index b34898a..b554684 100644 --- a/src/main/resources/views/platform/zhghh5/sys/home/mine.js +++ b/src/main/resources/views/platform/zhghh5/sys/home/mine.js @@ -74,22 +74,23 @@ const mine = { 我的签名 - - - - +
@@ -150,9 +152,10 @@ const mine = { } }, methods: { - switchTab(tab) { + // tab 为底部页签,categoryName 为可选应用分类名称;通过事件交给首页切换并定位分类。 + switchTab(tab, categoryName = "") { this.$store.commit("setActiveTarBar", tab) - this.$emit("switch-tab", tab) + this.$emit("switch-tab", tab, categoryName) }, logout() { this.$dialog