多角色督办任务详情页:从权限矩阵到组件拆分的完整实现
一个页面,五种角色,六个状态。怎么把"谁在什么状态下能做什么"这套逻辑写成可维护的代码?本文以 Vue 3 + TypeScript 项目中的督办催办模块为例,从架构设计到关键实现逐一拆解。
一、业务难题
IPO 项目管理中有一个"督办催办"场景:高管发起一个督办任务,指定拆解人(decomposer)将任务拆成多个子任务,再由负责人(owner)执行,审核人(reviewer)审批完成,最后由结单人(closer)做最终关闭。
五种角色访问同一个详情页,但各自看到的内容和能做的操作完全不同。子任务还有自己的状态流转:
objectivec
DRAFT → WAIT_RECEIVE → IN_PROGRESS/OVERDUE → COMPLETION_REVIEW → COMPLETED → CLOSED
状态机有 6 种状态,角色有 5 种,这意味着潜在的 if-else 组合高达 30 种。如果用最朴素的做法------在模板里堆 v-if:
html
<el-button v-if="isOwner && status === 'WAIT_RECEIVE'">接收任务</el-button>
<el-button v-if="isReviewer && status === 'COMPLETION_REVIEW'">同意</el-button>
<el-button v-if="isOwner && (status === 'IN_PROGRESS' || status === 'OVERDUE')">提交进展</el-button>
<!-- 还有十几个按钮...... -->
不出两周,这段代码就会变成没人敢碰的"意大利面条"。核心矛盾很清晰:同一页面、同一接口返回的数据、不同用户看到不同 UI------怎么设计才能让复杂度可控?
二、权限模型:用配置表解出角色 × 状态矩阵
2.1 设计思路
面对 5×6 的矩阵,与其用条件判断堆叠,不如把它抽象成声明式的配置。核心原则就一句话:把"谁在什么状态下能干什么"变成数据,而不是逻辑。
2.2 角色推导:determineModes
页面加载后,接口返回任务数据(含 responsibleUserId、closeUserId、decomposerUserId、creator),第一步是判断"当前用户是什么角色"。
ts
function determineModes(raw: any, isSub: boolean, currentUserId: number): DetailMode[] {
const uid = String(currentUserId)
const responsible = String(raw.responsibleUserId)
const closer = String(isSub ? raw.decomposerUserId : raw.closeUserId)
const creator = String(raw.creator)
const modes: DetailMode[] = []
if (isSub) {
if (closer === uid) modes.push('reviewer')
if (responsible === uid) modes.push('owner')
if (creator === uid) modes.push('senior')
return modes
}
// 主任务
if (closer === uid) modes.push('closer')
if (responsible === uid) modes.push('decomposer')
if (creator === uid) modes.push('senior')
return modes
}
这里有一个关键设计决策:用户可能同时匹配多个角色,返回数组而非单个值 。比如发起人同时也是负责人时,两者按钮都展示。组件消费 modes[] 而非单一 mode,避免了"只能选一个"的信息丢失。
2.3 按钮定义:BUTTON_DEF 配置表
按钮的可见性不像传统项目那样在每个组件里写 v-if,而是集中在一张配置表里:
ts
export const BUTTON_DEF: Record<string, {
label: string
type: 'default' | 'primary' | 'success' | 'danger' | 'warning' | ''
show: (mode: DetailMode, subTaskStatus?: SubTaskStatus, progress?: number) => boolean
disabled?: (subTaskStatus?: SubTaskStatus, progress?: number) => boolean
}> = {
submit_progress: {
label: '提交进展', type: 'primary',
show: (m, s) => ['decomposer', 'owner'].includes(m)
&& (s === 'IN_PROGRESS' || s === 'OVERDUE'),
},
approve_review: {
label: '同意', type: 'success',
show: (m, s) => m === 'reviewer' && s === 'COMPLETION_REVIEW',
},
receive: {
label: '接收任务', type: 'primary',
show: (m, s) => m === 'owner' && s === 'WAIT_RECEIVE',
},
// ... 其余 9 个按钮定义
}
每个按钮的 show 是一个纯函数,接收角色和状态,返回是否可见。新增一个角色或按钮时,只需加一条配置,不用翻遍所有组件的 v-if。
2.4 按钮过滤层:useTaskButtons
有了 modes[] 和 BUTTON_DEF,最后一步是运行时的匹配计算:
ts
const visibleButtons = computed(() => {
if (!modes.value.length) return []
const info = taskDetail.value?.taskInfo
// 终态只展示返回按钮
if (info?.status === 'CLOSED' || info?.status === 'COMPLETED') {
return [{ key: 'back', label: '返回', type: '', disabled: false }]
}
const shown = new Set<string>()
const buttons: { key: string; label: string; type: string; disabled: boolean }[] = []
for (const m of modes.value) {
for (const [key, def] of Object.entries(BUTTON_DEF)) {
if (shown.has(key)) continue
if (def.show(m, status, info?.progress)) {
shown.add(key)
buttons.push({ key, label: def.label, type: def.type, disabled: def.disabled?.(...) ?? false })
}
}
}
return buttons
})
用 Set 去重:同一个按钮对两种角色同时可见时只出现一次。整个权限模型的流程是:
scss
接口数据 → determineModes() → modes[] → useTaskButtons → visibleButtons[] → ActionButtons 组件渲染
这一套下来,修改权限规则的代价从"改一堆组件"变成了"改一行配置"。
三、页面骨架:把复杂页面拆成可维护的零件
3.1 组件树
详情页(TaskDetailPage.vue)是核心,组件层级为:
bash
index.vue
├── TaskDetailPage ← 主容器:表单 + 按钮 + 子任务
│ ├── TaskHeader ← 标题、状态、任务编号、创建人
│ ├── DataForm × 4 ← 信息区 / 总进度 / 进度填报 / 进展说明
│ ├── SubTaskTable ← 子任务只读列表(decomposer/closer 可见)
│ ├── FlowRecordDrawer ← 流转记录抽屉
│ └── ActionButtons ← 底部操作栏(根据 visibleButtons 渲染)
└── DecomposeDrawer ← 独立于详情页的拆解抽屉(顶层管理)
DecomposeDrawer 没有嵌套在 TaskDetailPage 内部,而是提升到 index.vue 层级与详情页平级。因为它的打开/关闭生命周期完全独立于详情的刷新------拆解完成后只需触发父级刷新,抽屉本身不需要知道详情内部的表单状态。
3.2 Hooks 分层
四个 hook 分别承担不同层级的职责:
css
useTaskDetail ------ 数据层:fetch 详情 + 子任务、角色推导、全局错误
useTaskActions ------ 操作层:提交进展、接收、审批、驳回等所有 API 调用
useTaskButtons ------ 表现层:modes[] → 可见按钮列表
useDecompose ------ 独立场景:子任务拆解抽屉的状态机(增删改查 + 保存)
分层的好处:每个 hook 可以独立阅读和修改。比如要改"驳回时弹出的输入框文案",只需动 useTaskActions,不影响数据加载或按钮渲染逻辑。
3.3 数据流
单向向下,事件向上:
useTaskDetail.fetchDetail()拉数据 → 写入taskDetailref → 组件 watch 后调用reSetFormData回填表单- 用户点击按钮 →
ActionButtonsemitactionkey →TaskDetailPage的actionMap分发到对应useTaskActions方法 - 操作成功后
await fetchDetail()原地刷新,页面状态更新
没有全局 store,所有状态都在组合式 API 的 ref 中闭环。因为详情页的数据不需要跨页面共享。
四、三个核心交互场景
4.1 拆解子任务
拆解是最高频的操作。拆解人在 DecomposeDrawer(80% 宽度的抽屉)中编辑子任务列表。看几个关键设计点:
防重复空白行 :addRow 前检查是否存在标题为空的行,有则 skip 并提示,避免用户狂点新增按钮导致列表中出现 N 行空白。
ts
const addRow = () => {
if (subTaskList.value.some((t) => !t.title)) {
ElMessage.warning('请先填写当前空白行')
return
}
subTaskList.value.push(EMPTY_SUB_TASK())
}
编辑保护 :非 DRAFT 状态的子任务所有字段只读。isNotDraft 一行判断搞定,不必列举哪些状态能编辑:
ts
const isNotDraft = (row: { status: string }) => row.status !== 'DRAFT'
占比校验:保存时校验三项------必填项、每行占比 > 0、合计 = 100%。在抽屉底部用颜色提示(绿色 100%、红色超出、橙色不足)。
进度输入的小数支持 :ProgressInput 组件使用 parseFloat + 正则过滤非数字字符:
ts
function handleInput(val: string) {
const num = parseFloat(val.replace(/[^\d.]/g, ''))
if (!isNaN(num)) {
emit('update:value', Math.min(100, Math.max(0, num)))
}
}
4.2 提交进展
负责人(或拆解人)可以填入当前进度百分比 + 进展描述 + 附件。
验证规则:进度必须 > 0;申请完成时进度必须 = 100%。表单数据走 DataForm.getFormData() 提取,经 actionMap 分发:
ts
submit_progress: async () => {
if (!(await validateProgressReport())) return
const reportData = progressReportFormRef.value?.getFormData() || {}
const descData = descriptionFormRef.value?.getFormData() || {}
handleSubmitProgress(
reportData.progress || 0,
descData.description || '',
fromMulFile(reportData.attachments || [])
)
}
actionMap 是一个 key→函数 的映射表,按钮点击时只 emit key 字符串,由 handleAction 统一分发。这样做的好处是:ActionButtons 组件不需要知道任何业务逻辑,它只负责"渲染按钮列表 + 回传 key"。
4.3 审核流转
审核人和结单人的操作链路类似:确认弹窗 → 调接口 → 刷新。驳回比同意多一步------需要输入理由。
ts
const handleRejectApprove = async () => {
const result = await ElMessageBox.prompt('请输入驳回理由', {
inputType: 'textarea',
}).catch(() => null)
if (!result) return
// 调接口驳回,传入 reason
await rejectComplete({ taskId: info.id, reason: result.value })
ElMessage.success('已驳回')
await fetchDetail()
}
关键决策:所有操作完成后统一"留在当前页刷新"。之前的实现是操作后跳转到上级页面,用户如果想连续审批多个任务需要重复进入,体验割裂。改进后操作成功只刷新当前数据,用户可以立即看到最新状态并继续操作。
五、优化与打磨:从"能用"到"好用"
完成核心功能后,还有一批 P0-P2 的细节让页面更健壮:
错误重试 :fetchDetail 失败后页面展示 el-result error 组件,但之前没有重试入口,用户只能手动刷新浏览器。加了一个"重试"按钮,调用 refreshAll() 同步刷新详情和子任务列表。
空状态提示 :子任务列表为空时,之前只展示空表头,用户不确定是加载失败还是确实没有数据。改为 <el-empty description="暂未拆解子任务" />,同时在接口报错时展示错误结果。
无权限角色提示 :用户未匹配任何角色(modes 为空数组)时,按钮区一片空白。加了一条 <el-alert type="info">:"您不是该任务的负责人/审核人/创建人,仅可查看"------让用户知道原因而不是疑惑。
未保存确认:拆解抽屉关闭时,如果用户编辑了子任务但没有点确认,数据会丢失。改进方案是打开抽屉时保存初始快照,关闭时对比当前数据,有差异则弹确认框。
防多次提交 :所有操作的 submitLoading 统一管理,按钮在请求进行中全部禁用,避免重复提交。
六、总结:可复用的四种模式
这个模块做下来,有四个模式可以迁移到其他复杂详情页:
1. 配置驱动的权限模型
角色 × 状态的矩阵用声明式配置管理。新增角色只需加条目,不需要翻遍组件的条件分支。核心公式:determineModes() + BUTTON_DEF + useTaskButtons = 可维护的按钮控制。
2. Hooks 职责分层
数据层 / 操作层 / 表现层三层分离。每层只做一件事,每个 hook 的文件不超过 160 行。对比一个 500 行的"上帝 hook",四个 100 行的小 hook 更容易阅读和修改。
3. 以"操作场景"为边界的组件拆分
DecomposeDrawer 独立于 TaskDetailPage 不是因为视觉上它是抽屉,而是因为它代表一个完整的独立业务场景------有自己的 loading、错误、数据和行为。组件的边界应该跟业务操作对齐,而不是跟视觉区域对齐。
4. 防御性设计的清单
空状态、错误重试、未保存确认、防重复提交、输入容错------这些不是"锦上添花",而是前端质量的基本功。每一项都在几十行代码内解决,但每一项的缺失都会导致用户困惑或数据丢失。
模块源码位于 src/modules/project/pages/controlTower/superviseAndUrge/,基于 Vue 3 + TypeScript + Element Plus。
php
> 一个页面,五种角色,六个状态。怎么把"谁在什么状态下能做什么"这套逻辑写成可维护的代码?本文以 Vue 3 + TypeScript 项目中的督办催办模块为例,从架构设计到关键实现逐一拆解。
---
## 一、业务难题
IPO 项目管理中有一个"督办催办"场景:高管发起一个督办任务,指定拆解人(decomposer)将任务拆成多个子任务,再由负责人(owner)执行,审核人(reviewer)审批完成,最后由结单人(closer)做最终关闭。
五种角色访问同一个详情页,但各自看到的内容和能做的操作完全不同。子任务还有自己的状态流转:
```
DRAFT → WAIT_RECEIVE → IN_PROGRESS/OVERDUE → COMPLETION_REVIEW → COMPLETED → CLOSED
```
状态机有 6 种状态,角色有 5 种,这意味着潜在的 `if-else` 组合高达 30 种。如果用最朴素的做法------在模板里堆 `v-if`:
```html
<el-button v-if="isOwner && status === 'WAIT_RECEIVE'">接收任务</el-button>
<el-button v-if="isReviewer && status === 'COMPLETION_REVIEW'">同意</el-button>
<el-button v-if="isOwner && (status === 'IN_PROGRESS' || status === 'OVERDUE')">提交进展</el-button>
<!-- 还有十几个按钮...... -->
```
不出两周,这段代码就会变成没人敢碰的"意大利面条"。核心矛盾很清晰:**同一页面、同一接口返回的数据、不同用户看到不同 UI------怎么设计才能让复杂度可控?**
---
## 二、权限模型:用配置表解出角色 × 状态矩阵
### 2.1 设计思路
面对 5×6 的矩阵,与其用条件判断堆叠,不如把它抽象成声明式的配置。核心原则就一句话:**把"谁在什么状态下能干什么"变成数据,而不是逻辑**。
### 2.2 角色推导:determineModes
页面加载后,接口返回任务数据(含 `responsibleUserId`、`closeUserId`、`decomposerUserId`、`creator`),第一步是判断"当前用户是什么角色"。
``` ts
function determineModes(raw: any, isSub: boolean, currentUserId: number): DetailMode[] {
const uid = String(currentUserId)
const responsible = String(raw.responsibleUserId)
const closer = String(isSub ? raw.decomposerUserId : raw.closeUserId)
const creator = String(raw.creator)
const modes: DetailMode[] = []
if (isSub) {
if (closer === uid) modes.push('reviewer')
if (responsible === uid) modes.push('owner')
if (creator === uid) modes.push('senior')
return modes
}
// 主任务
if (closer === uid) modes.push('closer')
if (responsible === uid) modes.push('decomposer')
if (creator === uid) modes.push('senior')
return modes
}
```
这里有一个关键设计决策:**用户可能同时匹配多个角色,返回数组而非单个值**。比如发起人同时也是负责人时,两者按钮都展示。组件消费 `modes[]` 而非单一 `mode`,避免了"只能选一个"的信息丢失。
### 2.3 按钮定义:BUTTON_DEF 配置表
按钮的可见性不像传统项目那样在每个组件里写 `v-if`,而是集中在一张配置表里:
```ts
export const BUTTON_DEF: Record<string, {
label: string
type: 'default' | 'primary' | 'success' | 'danger' | 'warning' | ''
show: (mode: DetailMode, subTaskStatus?: SubTaskStatus, progress?: number) => boolean
disabled?: (subTaskStatus?: SubTaskStatus, progress?: number) => boolean
}> = {
submit_progress: {
label: '提交进展', type: 'primary',
show: (m, s) => ['decomposer', 'owner'].includes(m)
&& (s === 'IN_PROGRESS' || s === 'OVERDUE'),
},
approve_review: {
label: '同意', type: 'success',
show: (m, s) => m === 'reviewer' && s === 'COMPLETION_REVIEW',
},
receive: {
label: '接收任务', type: 'primary',
show: (m, s) => m === 'owner' && s === 'WAIT_RECEIVE',
},
// ... 其余 9 个按钮定义
}
```
每个按钮的 `show` 是一个纯函数,接收角色和状态,返回是否可见。新增一个角色或按钮时,只需加一条配置,不用翻遍所有组件的 `v-if`。
### 2.4 按钮过滤层:useTaskButtons
有了 `modes[]` 和 `BUTTON_DEF`,最后一步是运行时的匹配计算:
```ts
const visibleButtons = computed(() => {
if (!modes.value.length) return []
const info = taskDetail.value?.taskInfo
// 终态只展示返回按钮
if (info?.status === 'CLOSED' || info?.status === 'COMPLETED') {
return [{ key: 'back', label: '返回', type: '', disabled: false }]
}
const shown = new Set<string>()
const buttons: { key: string; label: string; type: string; disabled: boolean }[] = []
for (const m of modes.value) {
for (const [key, def] of Object.entries(BUTTON_DEF)) {
if (shown.has(key)) continue
if (def.show(m, status, info?.progress)) {
shown.add(key)
buttons.push({ key, label: def.label, type: def.type, disabled: def.disabled?.(...) ?? false })
}
}
}
return buttons
})
```
用 `Set` 去重:同一个按钮对两种角色同时可见时只出现一次。整个权限模型的流程是:
```
接口数据 → determineModes() → modes[] → useTaskButtons → visibleButtons[] → ActionButtons 组件渲染
```
这一套下来,修改权限规则的代价从"改一堆组件"变成了"改一行配置"。
---
## 三、页面骨架:把复杂页面拆成可维护的零件
### 3.1 组件树
详情页(`TaskDetailPage.vue`)是核心,组件层级为:
```
index.vue
├── TaskDetailPage ← 主容器:表单 + 按钮 + 子任务
│ ├── TaskHeader ← 标题、状态、任务编号、创建人
│ ├── DataForm × 4 ← 信息区 / 总进度 / 进度填报 / 进展说明
│ ├── SubTaskTable ← 子任务只读列表(decomposer/closer 可见)
│ ├── FlowRecordDrawer ← 流转记录抽屉
│ └── ActionButtons ← 底部操作栏(根据 visibleButtons 渲染)
└── DecomposeDrawer ← 独立于详情页的拆解抽屉(顶层管理)
```
`DecomposeDrawer` 没有嵌套在 `TaskDetailPage` 内部,而是提升到 `index.vue` 层级与详情页平级。因为它的打开/关闭生命周期完全独立于详情的刷新------拆解完成后只需触发父级刷新,抽屉本身不需要知道详情内部的表单状态。
### 3.2 Hooks 分层
四个 hook 分别承担不同层级的职责:
```
useTaskDetail ------ 数据层:fetch 详情 + 子任务、角色推导、全局错误
useTaskActions ------ 操作层:提交进展、接收、审批、驳回等所有 API 调用
useTaskButtons ------ 表现层:modes[] → 可见按钮列表
useDecompose ------ 独立场景:子任务拆解抽屉的状态机(增删改查 + 保存)
```
分层的好处:每个 hook 可以独立阅读和修改。比如要改"驳回时弹出的输入框文案",只需动 `useTaskActions`,不影响数据加载或按钮渲染逻辑。
### 3.3 数据流
单向向下,事件向上:
- `useTaskDetail.fetchDetail()` 拉数据 → 写入 `taskDetail` ref → 组件 watch 后调用 `reSetFormData` 回填表单
- 用户点击按钮 → `ActionButtons` emit `action` key → `TaskDetailPage` 的 `actionMap` 分发到对应 `useTaskActions` 方法
- 操作成功后 `await fetchDetail()` 原地刷新,页面状态更新
没有全局 store,所有状态都在组合式 API 的 `ref` 中闭环。因为详情页的数据不需要跨页面共享。
---
## 四、三个核心交互场景
### 4.1 拆解子任务
拆解是最高频的操作。拆解人在 `DecomposeDrawer`(80% 宽度的抽屉)中编辑子任务列表。看几个关键设计点:
**防重复空白行**:`addRow` 前检查是否存在标题为空的行,有则 skip 并提示,避免用户狂点新增按钮导致列表中出现 N 行空白。
```ts
const addRow = () => {
if (subTaskList.value.some((t) => !t.title)) {
ElMessage.warning('请先填写当前空白行')
return
}
subTaskList.value.push(EMPTY_SUB_TASK())
}
```
**编辑保护**:非 DRAFT 状态的子任务所有字段只读。`isNotDraft` 一行判断搞定,不必列举哪些状态能编辑:
```ts
const isNotDraft = (row: { status: string }) => row.status !== 'DRAFT'
```
**占比校验**:保存时校验三项------必填项、每行占比 > 0、合计 = 100%。在抽屉底部用颜色提示(绿色 100%、红色超出、橙色不足)。
**进度输入的小数支持**:`ProgressInput` 组件使用 `parseFloat` + 正则过滤非数字字符:
```ts
function handleInput(val: string) {
const num = parseFloat(val.replace(/[^\d.]/g, ''))
if (!isNaN(num)) {
emit('update:value', Math.min(100, Math.max(0, num)))
}
}
```
### 4.2 提交进展
负责人(或拆解人)可以填入当前进度百分比 + 进展描述 + 附件。
验证规则:进度必须 > 0;申请完成时进度必须 = 100%。表单数据走 `DataForm.getFormData()` 提取,经 `actionMap` 分发:
```ts
submit_progress: async () => {
if (!(await validateProgressReport())) return
const reportData = progressReportFormRef.value?.getFormData() || {}
const descData = descriptionFormRef.value?.getFormData() || {}
handleSubmitProgress(
reportData.progress || 0,
descData.description || '',
fromMulFile(reportData.attachments || [])
)
}
```
`actionMap` 是一个 key→函数 的映射表,按钮点击时只 emit key 字符串,由 `handleAction` 统一分发。这样做的好处是:`ActionButtons` 组件不需要知道任何业务逻辑,它只负责"渲染按钮列表 + 回传 key"。
### 4.3 审核流转
审核人和结单人的操作链路类似:确认弹窗 → 调接口 → 刷新。驳回比同意多一步------需要输入理由。
```ts
const handleRejectApprove = async () => {
const result = await ElMessageBox.prompt('请输入驳回理由', {
inputType: 'textarea',
}).catch(() => null)
if (!result) return
// 调接口驳回,传入 reason
await rejectComplete({ taskId: info.id, reason: result.value })
ElMessage.success('已驳回')
await fetchDetail()
}
```
**关键决策:所有操作完成后统一"留在当前页刷新"**。之前的实现是操作后跳转到上级页面,用户如果想连续审批多个任务需要重复进入,体验割裂。改进后操作成功只刷新当前数据,用户可以立即看到最新状态并继续操作。
---
## 五、优化与打磨:从"能用"到"好用"
完成核心功能后,还有一批 P0-P2 的细节让页面更健壮:
**错误重试**:`fetchDetail` 失败后页面展示 `el-result error` 组件,但之前没有重试入口,用户只能手动刷新浏览器。加了一个"重试"按钮,调用 `refreshAll()` 同步刷新详情和子任务列表。
**空状态提示**:子任务列表为空时,之前只展示空表头,用户不确定是加载失败还是确实没有数据。改为 `<el-empty description="暂未拆解子任务" />`,同时在接口报错时展示错误结果。
**无权限角色提示**:用户未匹配任何角色(`modes` 为空数组)时,按钮区一片空白。加了一条 `<el-alert type="info">`:"您不是该任务的负责人/审核人/创建人,仅可查看"------让用户知道原因而不是疑惑。
**未保存确认**:拆解抽屉关闭时,如果用户编辑了子任务但没有点确认,数据会丢失。改进方案是打开抽屉时保存初始快照,关闭时对比当前数据,有差异则弹确认框。
**防多次提交**:所有操作的 `submitLoading` 统一管理,按钮在请求进行中全部禁用,避免重复提交。
---
## 六、总结:可复用的四种模式
这个模块做下来,有四个模式可以迁移到其他复杂详情页:
**1. 配置驱动的权限模型**
角色 × 状态的矩阵用声明式配置管理。新增角色只需加条目,不需要翻遍组件的条件分支。核心公式:`determineModes() + BUTTON_DEF + useTaskButtons = 可维护的按钮控制`。
**2. Hooks 职责分层**
数据层 / 操作层 / 表现层三层分离。每层只做一件事,每个 hook 的文件不超过 160 行。对比一个 500 行的"上帝 hook",四个 100 行的小 hook 更容易阅读和修改。
**3. 以"操作场景"为边界的组件拆分**
`DecomposeDrawer` 独立于 `TaskDetailPage` 不是因为视觉上它是抽屉,而是因为它代表一个完整的独立业务场景------有自己的 loading、错误、数据和行为。组件的边界应该跟业务操作对齐,而不是跟视觉区域对齐。
**4. 防御性设计的清单**
空状态、错误重试、未保存确认、防重复提交、输入容错------这些不是"锦上添花",而是前端质量的基本功。每一项都在几十行代码内解决,但每一项的缺失都会导致用户困惑或数据丢失。
---
*模块源码位于 `src/modules/project/pages/controlTower/superviseAndUrge/`,基于 Vue 3 + TypeScript + Element Plus。*