From ca4220f92ce4cd03cf6bc479d3889b3374bea861 Mon Sep 17 00:00:00 2001 From: lzq <357099073@qq.com> Date: Tue, 29 Sep 2026 18:25:09 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=AA=E4=BA=BA=E4=B8=AD?= =?UTF-8?q?=E5=BF=83=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/api/crmPipeline.js | 22 +- backend/src/api/user.js | 12 + backend/src/components/TenantSwitcher.vue | 33 +- backend/src/stores/menu.js | 7 +- backend/src/utils/request.js | 22 +- .../apps/crm/business/components/detail.vue | 88 ++++++ .../views/apps/crm/clue/components/detail.vue | 62 +++- .../views/apps/crm/clue/components/edit.vue | 299 +++++++++++++++++- backend/src/views/apps/crm/clue/index.vue | 3 + .../crm/contract/components/ProductList.vue | 6 +- .../contract/components/detail_payback.vue | 47 ++- backend/src/views/apps/crm/contract/index.vue | 13 +- .../apps/crm/customer/components/edit.vue | 77 ++++- .../apps/crm/payback/components/create.vue | 81 +---- .../apps/crm/payback/components/detail.vue | 31 +- .../apps/crm/product/components/edit.vue | 28 +- .../apps/crm/project/components/detail.vue | 33 +- .../apps/crm/project/components/edit.vue | 27 ++ backend/src/views/apps/crm/project/index.vue | 30 +- backend/src/views/user/userProfile.vue | 64 +++- go/conf/app.conf | 15 + go/controllers/app_auth.go | 4 +- go/controllers/backend_admin_user.go | 99 ++++-- go/controllers/backend_auth.go | 20 +- go/controllers/backend_crm_business.go | 87 +++++ go/controllers/backend_crm_clue.go | 66 +++- go/controllers/backend_crm_contract.go | 77 +---- go/controllers/backend_crm_payback.go | 8 +- go/controllers/backend_crm_permission.go | 4 +- go/controllers/backend_crm_project.go | 159 ++++++---- go/controllers/backend_file.go | 138 +++++++- go/controllers/platform_product.go | 7 + go/controllers/platform_tenant_user.go | 44 ++- go/middleware/jwt.go | 5 + go/middleware/plan.go | 155 +++++++++ go/routers/backend/backend.go | 4 + go/routers/router.go | 4 + go/services/auth/sync.go | 17 +- go/services/oa_document.go | 5 +- go/services/operation_log_user.go | 3 +- go/services/payback_overdue_scheduler.go | 59 ++++ go/services/tenant_quota.go | 75 ++++- go/services/tenant_user.go | 18 ++ go/services/user_avatar.go | 45 +++ 44 files changed, 1793 insertions(+), 310 deletions(-) create mode 100644 go/middleware/plan.go create mode 100644 go/services/user_avatar.go diff --git a/backend/src/api/crmPipeline.js b/backend/src/api/crmPipeline.js index 897fc55..711775e 100644 --- a/backend/src/api/crmPipeline.js +++ b/backend/src/api/crmPipeline.js @@ -33,6 +33,14 @@ export function convertClueToBusiness(id, data) { return request({ url: `/backend/crm/clue/${id}/convert`, method: "post", data }); } +/** + * 线索状态流转:1跟进中 / 3已废弃。 + * 「已转化(2)」不走本接口,须用 convertClueToBusiness 完成(会落地客户并锁定线索)。 + */ +export function changeClueStatus(id, status) { + return request({ url: `/backend/crm/clue/${id}/status`, method: "post", data: { status } }); +} + /* --------------------------------- 商机 --------------------------------- */ export function getBusinessList(params) { @@ -60,6 +68,11 @@ export function convertBusinessToProject(id, data) { return request({ url: `/backend/crm/business/${id}/convert`, method: "post", data }); } +/** 商机阶段流转:1初步接洽 2需求确认 3方案报价 4商务谈判 5赢单 6输单 */ +export function changeBusinessStage(id, stage) { + return request({ url: `/backend/crm/business/${id}/stage`, method: "post", data: { stage } }); +} + /* --------------------------------- 项目 --------------------------------- */ export function getProjectList(params) { @@ -82,9 +95,12 @@ export function deleteProject(id) { return request({ url: `/backend/crm/project/${id}`, method: "delete" }); } -/** 项目状态流转:仅更新状态(1未开始 / 2进行中 / 3已完成 / 4已暂停 / 5异常) */ -export function changeProjectStatus(id, status) { - return request({ url: `/backend/crm/project/${id}/status`, method: "post", data: { status } }); +/** + * 项目状态流转:仅更新状态(1未开始 / 2进行中 / 3已完成 / 4已暂停 / 5异常)。 + * status=5(异常)时必须传 reason,后端会校验并写入操作日志。 + */ +export function changeProjectStatus(id, status, reason = "") { + return request({ url: `/backend/crm/project/${id}/status`, method: "post", data: { status, reason } }); } /** diff --git a/backend/src/api/user.js b/backend/src/api/user.js index e90d0a8..df701ca 100644 --- a/backend/src/api/user.js +++ b/backend/src/api/user.js @@ -63,6 +63,18 @@ export function bindEmail(userId, email) { return editUser(userId, { email }); } +// 上传并设置当前登录用户头像(一步到位:上传文件 + 写入认证中心头像) +// formData 需包含 file 字段;返回 data.url 为头像地址 +export function uploadAvatar(formData) { + return request({ + url: '/backend/uploadavatar', + method: 'post', + data: formData, + // 上传不设超时;不要手写 Content-Type,需浏览器生成 multipart boundary + timeout: 0, + }); +} + // 删除用户 export function deleteUser(userId) { return request({ diff --git a/backend/src/components/TenantSwitcher.vue b/backend/src/components/TenantSwitcher.vue index a2bb26c..cb34581 100644 --- a/backend/src/components/TenantSwitcher.vue +++ b/backend/src/components/TenantSwitcher.vue @@ -106,6 +106,28 @@ async function loadTenants() { } } +/** + * 企业切换后,上一家的菜单 / 标签页 / 企业信息缓存都必须失效。 + * 菜单虽然是按 tid 建 key,但旧 key 仍会留在本地,顺手清掉可避免脏数据累积。 + */ +function clearTenantScopedCache() { + try { + const removeKeys: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && (key.startsWith("menu_cache_") || key === "tabs_list" || key === "active_tab")) { + removeKeys.push(key); + } + } + removeKeys.forEach((key) => localStorage.removeItem(key)); + sessionStorage.removeItem("tabs_list"); + localStorage.removeItem("tenant"); + sessionStorage.removeItem("tenant"); + } catch (e) { + console.warn("清理企业级缓存失败:", e); + } +} + async function handleSwitch(tid: number | string) { if (tid === RELOAD_COMMAND) { await loadTenants(); @@ -115,9 +137,16 @@ async function handleSwitch(tid: number | string) { const target = tenantOptions.value.find((item) => String(item.tid) === String(tid)); try { await switchTenant(tid); + clearTenantScopedCache(); ElMessage.success(`已切换到「${target?.tenant_name || "新企业"}」`); - // 菜单、按钮权限、模块套餐均按企业隔离,整页重载确保全部按新企业重新初始化 - setTimeout(() => window.location.reload(), 300); + // 切企业后必须离开当前页:当前页可能属于上一家才开通的模块, + // 停留在原地会让新企业未开通的功能继续可用。回到首页(门户)锁死入口, + // 菜单/权限/套餐再按新企业整页初始化。 + // 用 replaceState 改 hash(不触发路由跳转),避免用旧企业状态多渲染一次 + setTimeout(() => { + window.history.replaceState({}, document.title, `${window.location.pathname}#/home`); + window.location.reload(); + }, 300); } catch (e: any) { ElMessage.error(e?.message || "切换企业失败"); } diff --git a/backend/src/stores/menu.js b/backend/src/stores/menu.js index 803dec5..61b0aaf 100644 --- a/backend/src/stores/menu.js +++ b/backend/src/stores/menu.js @@ -16,13 +16,16 @@ export const useMenuStore = defineStore('menu', () => { // 正在加载的 Promise(用于避免重复请求) let loadingPromise = null; - // 菜单缓存 key(基于用户类型和角色ID) + // 菜单缓存 key(基于用户类型、当前企业与角色ID) + // 必须带 tid:菜单是按企业下发的,key 里没有企业会让「切换企业」后 + // 直接复用上一家的菜单,导致新企业未开通的模块仍能进入。 const getCacheKey = () => { try { const userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}'); const loginType = userInfo.type || 'user'; const roleId = userInfo.group_id || 0; - return `menu_cache_${loginType}_${roleId}`; + const tid = userInfo.tid || 0; + return `menu_cache_${loginType}_${tid}_${roleId}`; } catch (e) { return 'menu_cache_default'; } diff --git a/backend/src/utils/request.js b/backend/src/utils/request.js index caf1431..81433a3 100644 --- a/backend/src/utils/request.js +++ b/backend/src/utils/request.js @@ -51,27 +51,27 @@ service.interceptors.response.use( const bizMsg = error.response.data?.msg || error.response.data?.message; switch (error.response.status) { case 401: { - // 每个请求最多用新令牌重放一次,避免「401 → 刷新 → 仍 401」反复刷新 - if (error.config?._tokenRetried) { - return Promise.reject(new Error(bizMsg || 'token无效,请重新登录')); - } - // 并发场景下,本请求可能带的是「旧令牌」,而别的请求已经把令牌换成新的 + // 阶段一:并发场景下,本请求可能带的是「旧令牌」,而别的请求已经把令牌换成新的 // (登录回跳、另一请求的静默续期都会改 localStorage)。 - // 此时直接用最新令牌重放即可,既省掉一次刷新,也不会误报登录失效。 + // 此时直接用最新令牌重放,免得白跑一次续期。 const latestToken = localStorage.getItem('token') || ''; const sentToken = String(error.config?.headers?.Authorization || '').replace(/^Bearer\s+/i, ''); - if (latestToken && latestToken !== sentToken) { + if (!error.config?._replayedWithLatest && latestToken && latestToken !== sentToken) { console.warn('[auth] 请求携带的令牌已过期,改用最新令牌重放:', error.config?.url); error.config.headers['Authorization'] = `Bearer ${latestToken}`; - error.config._tokenRetried = true; + error.config._replayedWithLatest = true; return service.request(error.config); } - // 统一认证:先用 refresh_token 静默换取新令牌并重放原请求, - // 刷新失败(令牌过期/被吊销/旧本地登录遗留 token)才真正登出。 + // 阶段二:仍 401 → 用 refresh_token 静默续期后重放一次。 + // 注意「重放」与「续期」是两个独立阶段:前者不消耗 refresh_token, + // 只挡一次;否则令牌真的过期时就永远等不到续期,只会一路报「无效的token」。 + if (error.config?._refreshed) { + return Promise.reject(new Error(bizMsg || 'token无效,请重新登录')); + } try { const newToken = await refreshAccessToken(); error.config.headers['Authorization'] = `Bearer ${newToken}`; - error.config._tokenRetried = true; + error.config._refreshed = true; return service.request(error.config); } catch (e) { // 续期失败:清空登录态并跳认证中心重新登录(只跳一次,避免死循环) diff --git a/backend/src/views/apps/crm/business/components/detail.vue b/backend/src/views/apps/crm/business/components/detail.vue index 915b4d4..df2eb4b 100644 --- a/backend/src/views/apps/crm/business/components/detail.vue +++ b/backend/src/views/apps/crm/business/components/detail.vue @@ -7,6 +7,40 @@ @update:model-value="handleClose" @opened="loadAll" > + + +
@@ -62,6 +96,10 @@ diff --git a/backend/src/views/apps/crm/clue/index.vue b/backend/src/views/apps/crm/clue/index.vue index f9fecc7..062da51 100644 --- a/backend/src/views/apps/crm/clue/index.vue +++ b/backend/src/views/apps/crm/clue/index.vue @@ -81,6 +81,9 @@ + + +