ManageScreen · Technical Deep Dive

新屏入口逻辑
深度解析

从路由守卫 → layout初始化 → 头部交互 → 自选弹窗 → 联调参考
每一步为什么这么做,每一行代码的设计意图

Vue 3.3 + Pinia Vue Router 4 Element Plus 联调参考

01 — ARCHITECTURE

全局架构总览

用户从URL进入,到页面渲染完成,经历了几个关键节点

main.js
App引导
permission.js
路由守卫
layout.vue
核心初始化
header.vue
控件区
子路由页面
branchCompany等
核心设计原则

layout.vue 是整个新屏的"中枢"——它负责初始化用户信息、决定路由方向、派发全局事件。所有子页面通过 Pinia Store 获取状态,不直接与 layout 通信。这种"中枢 + 订阅"模式让子页面完全解耦,可独立开发和联调。

02 — ROUTE GUARD

路由守卫 permission.js

用户请求的第一道关卡 — 决定是否放行

src/permission.js const whitePaths = [ '/screen', '/manageScreen', '/workOrder', '/workOrderPdfs' ] router.beforeEach(async (to, from, next) => { const ruleType = useUserStore().pageRuleType if (whitePaths.some(p => to.path === p || to.path.startsWith(p + '/') )) { next() } else if (ruleType === '') { next('/screen') } else { next() } })
1
白名单匹配

some + startsWith 匹配路径前缀,而非精确匹配。这样 /manageScreen/branchCompany 也会被放行。

2
未初始化兜底

如果 pageRuleType 为空(首次进入、刷新),强制跳转 /screen。因为新屏需要从 layout.vue 的 getInfo 获取 ruleType 后才能路由到正确子页面。

3
为什么不在这里做鉴权?

本项目嵌入在联通内部系统 iframe 中,已有外层统一登录。路由守卫只做「路径合法性」校验,不做 token/session 检查。

为什么用 startsWith 而非完整路径枚举

新屏的子路由(branchCompany / grid / campService)是动态的,且可能后续新增。用前缀匹配避免每新增一个子路由就要同步更新白名单。

03 — BOOTSTRAP

main.js 应用引导

App实例的创建与插件注册顺序

src/main.js import './permission' // ① 路由守卫挂载 const app = createApp(App) app.use(router) // ② Router app.use(store) // ③ Pinia Store app.use(directive) // ④ 自定义指令 app.use(elementIcons) // ⑤ Element Plus 图标 app.mount('#app') // ⑥ 挂载
! 关键顺序:permission 在 mount 之前
import './permission' 是在模块顶层执行的,它注册了 router.beforeEach。这意味着在 Vue app 挂载前,路由守卫就已就绪。

app.mount('#app') 触发首次路由解析时,守卫已经在工作了。
R 路由基础路径
createWebHistory('/wghtml/asiahtml/budgetv/')

部署在联通内部目录下,这个 base path 让 Vue Router 在解析 /manageScreen 时实际对应 /wghtml/asiahtml/budgetv/manageScreen

所有 router.push 路径都不需要带这个前缀。

04 — LAYOUT.VUE

layout.vue 核心中枢

新屏最重要的文件 — 模板仅 9 行,逻辑承载整个初始化链路

template 部分 <template> <HeaderComp @dateChange="handleDateChange" @orgChange="handleOrgChange" @tabChange="handleTabChange" @dimensionChange="handleDimensionChange" /> <router-view /> </template>
H
HeaderComp — 全局控件区

头部组件通过 4 个事件向 layout 报告用户操作。layout 不渲染任何业务UI,只做"事件转发 → Store更新"。

R
router-view — 子页面出口

branchCompany / grid / campService 渲染在这里。子页面通过 watch(useUserStore().userInfo) 响应 Store 变化,不直接与 layout 通信。

为什么 layout 只有 Header + router-view

「单一职责」:layout 专注于全局状态初始化和事件中继。业务展示完全委托给子路由。这样切换 ruleType(分公司→网格→营服)时,只需切换子路由,layout 无需改动。

05 — URL PARAMS

getUrlParams() URL参数解析

为什么不能直接用 window.location.search?

layout.vue · getUrlParams function getUrlParams() { // 关键:将 # 替换为 %23 const transformURL = new URL( window.location.href.replace(//#/g, '%23') ) const params = new URLSearchParams( transformURL.search ) const paramObj = {} for (const [key, value] of params.entries()) { paramObj[key] = value } return paramObj }
? 为什么要 replace(/#/g, '%23')
虽然项目使用 createWebHistory(history 模式),但外部系统(联通内部平台)跳转过来的 URL 可能包含 hash 符号 #

new URL() 会把 # 后面的内容当作 fragment,导致 searchParams 解析失败。

先将 # 编码为 %23,让 URL 构造器正确解析 query 参数。
联调提示

如果外部系统传参格式变了(比如换成 JSON 嵌在某个参数里),只需改这一个函数。当前主要用于 ruleType 参数覆盖。

06 — INITIALIZATION

getInfo() 核心初始化链

页面加载时自动执行,是新屏一切逻辑的起点

layout.vue · getInfo // Step 0: 进入即全屏 Loading const loadingInstance = ElLoading.service({ fullscreen: true }) async function getInfo() { const urlParams = getUrlParams() // Step 1 try { const res = await spost( // Step 2: 请求后端 userInfoStore, '/ManagementChart/getBaseInfo', {} ) if (urlParams.ruleType) { // Step 3: URL 参数覆盖 res.d.ruleType = urlParams.ruleType } await toPageRuter(res.d.ruleType) // Step 4: 先路由! useUserStore().$patch((state) => { // Step 5: 后写 Store state.userInfo = { ...fields } }) } finally { loadingInstance.close() // Step 6: 关闭 Loading } } getInfo() // 立即执行
关键:Step 4 和 Step 5 的顺序不能反

toPageRuter(路由跳转)再 $patch(写 Store)。如果反过来,子页面的 watch(userInfo) 会在路由切换前触发,导致旧子页面收到新数据,产生闪烁或错误请求。

07 — HTTP UTILITY

spost 请求机制 plugins/spost.js

自研的响应式 HTTP 工具,基于 PromiseState 模式

PromiseState 类
每个请求返回一个 reactive 对象,包含:

p — pending(请求中)
o — ok(成功)
e — error(失败)
c — code(响应码)
m — message(响应消息)
d — data(响应数据)
s — sequence(请求序号)

模板中可直接绑定 store.p 显示 loading 状态。
FormData + JsonParam 序列化
默认将参数包装为 FormData:

formData.append('JsonParam', JSON.stringify(params))

后端接口统一从 JsonParam 字段解析参数。这是联通内部框架的约定。

联调时,所有接口的参数都要放在 JsonParam 里,不是 JSON body。
联调要点

接口返回格式:{ code, message, data }。spost 自动将 data 赋值给 store.d,失败时弹 ElMessage.error

08 — ROUTING DECISION

toPageRuter() 路由决策引擎

根据 ruleType 将用户导向正确的子页面

layout.vue · toPageRuter async function toPageRuter(ruleType) { let routePromise // 关键:先设 pageRuleType useUserStore().pageRuleType = ruleType if (['13','135','136'].includes(ruleType)) { // 营服中心 routePromise = router.push( '/manageScreen/campService' ) } else if (['01','02'].includes(ruleType)) { // 分公司 routePromise = router.push( '/manageScreen/branchCompany' ) } else { // 网格 routePromise = router.push( '/manageScreen/grid' ) } await routePromise }
!
pageRuleType vs userInfo.ruleType

直接赋值 pageRuleType,而不是通过 $patch userInfo.ruleType。因为子页面 watch(userInfo) 是 deep 的——如果先改 userInfo.ruleType,watch 立即触发,子页面在路由切换之前就开始请求接口,产生竞态。

?
ruleType 值对照表

01 02 → 分公司
13 135 136 → 营服中心
其他 → 网格

为什么要 await routePromise

确保子页面组件已挂载完毕后,再执行 $patch 写 Store。子页面的 watch 监听器是在组件 setup 中创建的,如果不 await,可能 Store 更新时组件还未挂载,watch 不会触发。

09 — STATE MANAGEMENT

Store 写入策略

userInfo 的每个字段何时写入、为什么这样写

layout.vue · $patch useUserStore().$patch((state) => { state.userInfo = { ...state.userInfo, // 保留已有字段 ruleType: res.d.ruleType, // 组织类型 sellArea: res.d.sellArea, // 组织编码 sellAreaDesc: res.d.sellAreaDesc, // 组织名称 dayId: res.d.dayId, // 当前日期 monthId: res.d.monthId, // 当前月份 dimension: 'LJ' // 默认维度:累计 } })
1 使用 $patch 而非直接赋值
$patch 只触发一次响应式更新(批量)。如果分别写 state.userInfo.ruleType = ...state.userInfo.sellArea = ...,会触发多次 watch,子页面可能连续发出多个接口请求。

$patch 保证子页面只收到一次完整的数据更新。
D dimension 默认值 'LJ' 的含义
LJ = 累计(cumulative),DY = 当月(current month)。

初始化时固定为累计维度。用户可通过 Header 的维度切换按钮改为当月。子页面请求接口时会携带这个维度参数,影响查询的时间范围。

10 — ORG CHANGE

handleOrgChange 组织切换的精密流程

4个事件处理器中最复杂的一个 — 可能触发路由跳转

layout.vue · handleOrgChange async function handleOrgChange(org) { // ① 先路由跳转 await toPageRuter(org.ruleType) // ② 再更新 Store(子页面已就位) useUserStore().$patch((state) => { state.userInfo.ruleType = org.ruleType state.userInfo.sellArea = org.id state.userInfo.sellAreaDesc = org.text state.userInfo.zqgzType = org.zqgzType }) }
为什么 orgChange 要先路由再 patch

用户在组织树选择时可能跨层级:从「分公司」跳到「营服」。这需要切换子路由(branchCompany → campService)。如果先 patch,旧页面会用新数据请求接口(参数不匹配),导致接口报错。

handleDateChange(date)
直接 $patch monthId。月份切换不涉及路由跳转,子页面 watch 到 monthId 变化后重新请求接口。
handleTabChange(tab)
直接 $patch tabType。切换核算方式(如"全口径/收入"),不涉及路由。子页面根据 tabType 切换展示的指标集。
handleDimensionChange(val)
直接 $patch dimension。LJ(累计)/DY(当月) 切换。子页面根据维度决定查询时间范围和展示格式。

11 — HEADER COMPONENT

header.vue 4大控件区详解

维度 · 核算方式 · 组织树 · 日期选择器 — 每个控件的内部机制

D 维度切换 dimensionChange
两个按钮:LJ(累计) / DY(当月)

点击时 emit('dimensionChange', val)。layout 将值写入 Store,子页面据此决定查询的时间维度。

初始值:来自 Store 的 userInfo.dimension,首次为 'LJ'
T 核算方式 tabChange
tabs 组件,不同 ruleType 展示不同 tab 列表。默认 'INDEX_VALUE_JYZRZT_SJKJHJ'

通过 ruleArr = ['01','02','13','136','135','137'] 判断是否显示额外 tooltip 提示。

作用:子页面根据 tabType 决定展示哪组指标。
O 组织树 orgChange
基于 el-tree-select 实现,懒加载
· 根节点: /tree/sellAreaNewJfysJs
· 下钻: /tree/sellAreaDrillNewJfysJs

选中节点时 emit orgChange({ruleType, id, text, zqgzType})。根级节点额外携带 zqgzType
M 日期选择器 dateChange
Element Plus 的 el-date-picker,type 为 month

初始值从 getBaseInfo 接口返回的 monthId

切换月份后 emit dateChange(month),layout 更新 Store,所有子页面重新查询该月数据。

12 — LAZY TREE

组织树懒加载机制

两级接口 + el-tree-select lazy 模式的协作

第一级:根节点请求
接口: /tree/sellAreaNewJfysJs
时机: header 组件 mounted 时
返回: 顶级组织列表(分公司/营服/网格)
第二级:下钻请求
接口: /tree/sellAreaDrillNewJfysJs
时机: 用户展开节点时触发
参数: 父节点的 sellArea + ruleType
选中回调
emit orgChange({ruleType, id, text, zqgzType})
根级节点额外返回 zqgzType
→ layout.handleOrgChange → toPageRuter → $patch
为什么用懒加载而非一次加载全部
组织树总节点数可达数千(联通各省市区县网格)。一次加载全部树数据会导致:
· 首屏接口耗时过长
· 前端渲染卡顿
· 大部分节点用户永远不会点开

懒加载 = 用户展开时才请求子节点,首屏只加载十几个根节点。
联调注意

下钻接口需要在 JsonParam 中传递 sellArea(父节点编码)和 ruleType(组织类型)。如果某节点是叶子节点,后端返回空数组,前端自动标记为 isLeaf: true

zqgzType 字段

仅根级节点返回此字段,表示"增长工作类型"。子页面(如分公司底部模块)可能需要这个参数来筛选特定指标。Header 在根级选中时会把 zqgzType 写入 Store。

13 — DATA SUBSCRIPTION

子页面数据订阅模式

branchCompany/index.vue 如何响应 layout 的状态变化

branchCompany/index.vue const form = ref({ dateTime: '', sellArea: '', ruleType: '', text: '', tabType: 'INDEX_VALUE_JYZRZT_SJKJHJ' }) const srType = ref('LJ') watch( () => useUserStore().userInfo, (newUserInfo) => { srType.value = newUserInfo.dimension form.value = { dateTime: newUserInfo.monthId, sellArea: newUserInfo.sellArea, ruleType: newUserInfo.ruleType, text: newUserInfo.sellAreaDesc, tabType: newUserInfo.tabType || 'INDEX_VALUE_JYZRZT_SJKJHJ' } }, { deep: true } )
1
深度监听 userInfo

deep: true 确保 userInfo 内任何字段变化都会触发回调。因为 layout 使用 $patch,一次 patch 只触发一次 watch。

2
本地 form 解耦

子页面用 form 作为本地副本,而不是直接传递 Store 到子组件。这样子组件的 props 是简单对象,避免了子组件直接依赖 Store。

3
srType v-model 透传

维度值通过 v-model:srType 传给 topContent,再传给 income 组件。income 组件点击收入类型按钮时可以修改 srType。

为什么 tabType 有默认值兜底

首次 getInfo 接口不返回 tabType,Store 中初始也没有。|| 'INDEX_VALUE_JYZRZT_SJKJHJ' 确保子页面始终有一个有效的 tab,不会因为 undefined 导致组件渲染异常。

14 — INCOME DIALOG

自选弹窗设计 (收入)

yourSelfDialog.vue — 用户选择自定义展示哪些指标

  • 联网收入 (fixed, disabled)
    • 移宽固 → 移动 / 宽带 / 固话
    • BPO
    • 专线联网 → 子项
    • 物联网连接
  • 算网收入 (fixed, disabled)
    • 项目 → 5个子项
    • 标品 → 4个子项
    • 数据中心
  • 移宽规模稳盘工程
  • 百万智家组网工程
  • 云智产品日新工程
A 两个接口
获取列表:getUserZxZbData
返回用户当前已选的指标列表,含固定项和动态项。

保存选择:updateUserZbInfo
将用户勾选的指标 ID 列表提交后端。后端记住用户偏好,下次打开恢复选中状态。
F 固定项不可取消
保存时过滤固定项 ID:
01010100000101020000(收入)
QSJRHZK(业务)

这些固定项始终展示,用户只能选择/取消动态指标。checkbox 的 disabled 属性阻止交互。
联调要点

接口返回的数据结构需要包含 idlabelchecked(是否已选)、disabled(是否固定)、children(子节点)字段。前端据此渲染树形 checkbox。

15 — COST DIALOG

成本自选弹窗 costDialog.vue

与收入弹窗结构类似,但全部为固定展示项

  • 联网通信成本
    • 移宽固
    • 物联网连接
  • 算网数智成本
    • 项目
    • 标品
    • 数据中心
    • 第三方设备
  • 移宽固
  • 国际费用租赁
  • 本地
与收入弹窗的关键区别
收入弹窗:固定项 + 动态可选项,有保存接口

成本弹窗:目前全部为固定项,没有动态可选部分。底部预留了 3 个占位成本项,等待后端接口就绪后扩展。

设计上保持了与收入弹窗一致的 UI 结构(树形 checkbox + 确认/取消按钮),便于后续统一扩展为可自选模式。
为什么成本弹窗先做成纯展示

产品第一期只要求收入指标可自选,成本指标暂时固定展示。但弹窗的 UI 骨架已就位,后端接口就绪后只需:1) 接入 API 获取动态成本项,2) 移除 disabled 属性,3) 添加保存逻辑。改动量极小。

16 — INDICATOR CARD

IndicatorCard 表格式指标卡

新屏核心展示组件 — 替代旧屏的 incomeCard/businessCard

Props 接口
Prop类型说明
titleString卡片标题(如"基准指标")
titleColorString标题主题色
dataArray指标数据列表
columnsArray列配置 {label, width, key}
titleColumnsArray表头列配置
isClickBoolean行是否可点击
E
展开/收起

数据超过 3 条时自动显示"展开"按钮。收起时只显示前 3 条。避免指标过多时页面过长。

S
行选中 + 渐变边框

isClick=true 时,点击行高亮并显示渐变边框。选中行的数据通过 emit 传给父组件,用于展开趋势图弹窗。

I
图标智能匹配

根据 title 关键字(移动/宽带/固话/项目...)自动匹配对应图标。无需外部传入图标路径。

17 — UTILITY COMPONENTS

CompareValue & DrawerDialog

两个高复用辅助组件的设计细节

C CompareValue 同比展示
颜色逻辑:
正值 → #e50017 红色(上升)
负值 → #22ac86 绿色(下降)
零值 → #8e9ca9 灰色(持平)

特殊文本处理:
· "去年无发生" — 去年无数据,无法计算同比
· "无预算目标" — 无预算基准,无法计算完成率

这两种情况直接显示文本,不走颜色逻辑。
D DrawerDialog 右侧抽屉
实现方式:
· Teleport(to="body") — 渲染到 body 下,避免被父级 overflow 裁切
· CSS transition — 右滑进出动画
· 遮罩层点击关闭
· 宽度: 35% 视口

为什么用 Teleport:
抽屉需要覆盖整个视口右侧。如果不 Teleport,它会被父级 .containeroverflow: auto 裁切。Teleport 让它脱离组件树渲染层级,直接挂在 body 上。

内容:通过 slot 插入,复用于趋势图、渠道筛选等场景。

18 — API REFERENCE

联调接口参考表

新屏涉及的所有接口 — 参数格式均为 FormData + JsonParam

接口路径 触发时机 JsonParam 参数 返回关键字段
/ManagementChart/getBaseInfo layout.vue 初始化 {} 空对象 ruleType, sellArea, sellAreaDesc, dayId, monthId
/tree/sellAreaNewJfysJs header mounted(根节点) {} 空对象 组织树根节点列表
/tree/sellAreaDrillNewJfysJs 展开组织树节点 {sellArea, ruleType} 子节点列表 (id, label, isLeaf)
getUserZxZbData 打开收入自选弹窗 用户标识相关 已选指标列表 (id, label, checked, disabled)
updateUserZbInfo 收入自选弹窗保存 勾选的指标 ID 数组 保存结果
通用请求格式

所有接口统一使用 spost 发送。请求体为 FormData,参数放在 JsonParam 字段中(JSON 字符串)。响应格式:{code, message, data}

注意

当前子页面(income/cost/profit)内的数据展示部分仍使用 mock 数据。联调时需替换为真实接口调用,参数从 formsrType 中获取。

19 — SEQUENCE DIAGRAM

完整生命周期时序

从用户打开URL到页面完全渲染的每一步

1
用户打开 URL

/wghtml/asiahtml/budgetv/manageScreen → Vue Router 匹配 /manageScreen 路由

2
permission.js 拦截

白名单匹配 /manageScreennext() 放行

3
layout.vue 加载

组件 setup 执行 → 显示全屏 Loading → 调用 getInfo()

4
getInfo → getBaseInfo API

请求后端获取用户基础信息 → 解析 URL 参数 → 覆盖 ruleType(如有)

5
toPageRuter 路由决策

设置 pageRuleType → 根据 ruleType 路由到 branchCompany / campService / grid → await 确保子页面挂载

6
$patch 写入 Store

批量更新 userInfo → 子页面 watch 触发 → form 同步 → 子组件开始渲染/请求业务接口

7
header.vue 初始化

Header mounted → 请求组织树根节点 → 渲染日期选择器 → 同步 Store 中的维度/tab

8
Loading 关闭 → 页面就绪

finally { loadingInstance.close() } — 无论成功失败都关闭 Loading,用户看到完整页面

20 — INTEGRATION CHECKLIST

联调 Checklist

接口对接时的核心注意事项

1 请求格式
所有请求使用 FormData,参数放在 JsonParam 字段中。
不要用 application/json Content-Type。
使用 spost(store, url, params) 即可自动处理。
2 替换 Mock 数据
income/cost/profit 组件中的 mockData 需替换为真实接口。
参数从 props.form 获取:dateTimesellArearuleType
维度从 props.srType 获取。
3 Watch 触发时机
子页面 watch(form, handler, {deep: true}) 会在 Store 更新时触发。
确保接口请求在 watch 回调中发起,且 form 的每个字段都有值时才请求(防空参)。
4 PromiseState 使用
const store = newStore() 创建响应式 store。
store.p 判断加载中,store.d 获取数据。
模板中 v-loading="store.p" 即可自动管理 loading 状态。
Quick Start

联调新接口只需 3 步:1) const xxxStore = newStore() 创建 store,2) 在 watch 中调用 spost(xxxStore, '/接口路径', {参数}),3) 模板绑定 xxxStore.d 渲染数据。

联调参考手册

路由守卫 → 中枢初始化 → 头部控件 → 自选弹窗 → 子页面订阅
每一步都有明确的"为什么"

permission.js layout.vue header.vue spost.js yourSelfDialog.vue

键盘 ← → 翻页 · 底部按钮导航 · 右侧圆点快速跳转 · 内容超出可滚动查看