479 lines
17 KiB
Markdown
479 lines
17 KiB
Markdown
# 中试服务申请单 — 详情页改弹窗 + UI合规修复 实施计划
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 将中试服务申请单详情页从独立路由改为弹窗形式(照抄采集协议单的查看/编辑弹窗风格),并修复列表页按钮图标缺失。
|
||
|
||
**Architecture:** 新建 `ApplicationDetailDialog.vue`,从 `ApplicationDetail.vue` 复制全部业务逻辑,改 props-driven 弹窗接口。列表页 `index.vue` 引入弹窗组件替换路由跳转。照抄 `ProtocolViewDialog`(KPI+data-grid)和 `ProtocolFormDialog`(form-grid+section-card)的 CSS 风格。
|
||
|
||
**Tech Stack:** Vue 3 + Element Plus + TypeScript + SCSS
|
||
|
||
**设计规格:** `docs/superpowers/specs/2025-05-25-pilot-application-dialog-refactor-design.md`
|
||
|
||
---
|
||
|
||
## 文件结构
|
||
|
||
| 文件 | 操作 | 职责 |
|
||
|------|------|------|
|
||
| `src/views/pilot-service/pilot-application/ApplicationDetailDialog.vue` | **新建** | 详情弹窗组件,props-driven,照抄 ProtocolViewDialog + ProtocolFormDialog 风格 |
|
||
| `src/views/pilot-service/pilot-application/index.vue` | **修改** | 列表页:引入弹窗组件、替换路由跳转、补按钮图标 |
|
||
| `src/views/pilot-service/pilot-application/ApplicationDetail.vue` | **删除** | 被新组件完全替代 |
|
||
| `src/router/modules/sqxp.ts` | **修改** | 删除 PilotApplicationDetail 路由定义 |
|
||
|
||
**参考文件(照抄风格源,只读不修改):**
|
||
- `src/views/cell-storage/protocol/ProtocolViewDialog.vue` — KPI 摘要条、data-grid、section-card CSS
|
||
- `src/views/cell-storage/protocol/ProtocolFormDialog.vue` — form-grid、upload-card CSS
|
||
|
||
---
|
||
|
||
## Task 1: 创建 ApplicationDetailDialog.vue 弹窗壳层 + 基本信息Tab
|
||
|
||
**Files:**
|
||
- Create: `src/views/pilot-service/pilot-application/ApplicationDetailDialog.vue`
|
||
- Read: `src/views/pilot-service/pilot-application/ApplicationDetail.vue`(源文件,复制逻辑)
|
||
- Read: `src/views/cell-storage/protocol/ProtocolViewDialog.vue`(照抄 KPI + data-grid + section-card CSS)
|
||
|
||
- [ ] **Step 1: 复制 ApplicationDetail.vue 为 ApplicationDetailDialog.vue 并改造弹窗壳层**
|
||
|
||
从 `ApplicationDetail.vue` 复制全部内容到 `ApplicationDetailDialog.vue`,然后改造:
|
||
|
||
1. **替换 `<template>` 最外层**:移除 `<div class="page-container">` 和 `<header class="page-head">`(返回按钮+标题),改为 `el-dialog` 弹窗壳:
|
||
|
||
```vue
|
||
<template>
|
||
<el-dialog
|
||
:model-value="modelValue"
|
||
@update:model-value="emit('update:modelValue', $event)"
|
||
:title="dialogTitle"
|
||
width="1600px"
|
||
class="dialog-form-layout application-detail-dialog"
|
||
align-center
|
||
destroy-on-close
|
||
:close-on-click-modal="false"
|
||
>
|
||
<div
|
||
class="application-detail-body"
|
||
:class="{ 'is-loading': detailLoading }"
|
||
v-loading="detailLoading"
|
||
element-loading-text="加载中…"
|
||
>
|
||
<!-- detail-tabs sticky 导航 -->
|
||
<div class="detail-tabs">
|
||
<div
|
||
v-for="tab in detailTabs"
|
||
:key="tab.key"
|
||
class="detail-tab"
|
||
:class="{ active: activeTab === tab.key }"
|
||
@click="scrollToTab(tab.key)"
|
||
>
|
||
{{ tab.label }}
|
||
</div>
|
||
</div>
|
||
|
||
<template v-if="applicationDetail">
|
||
<!-- KPI 摘要条 -->
|
||
<div class="kpi-strip">
|
||
<div class="kpi-card">
|
||
<div class="kpi-card__icon"><el-icon><Document /></el-icon></div>
|
||
<div class="kpi-card__main">
|
||
<div class="kpi-label">申请单号</div>
|
||
<div class="kpi-value kpi-value--emphasis">{{ displayValue(applicationDetail.applicationNo) }}</div>
|
||
</div>
|
||
</div>
|
||
<div class="kpi-card">
|
||
<div class="kpi-card__icon"><el-icon><CircleCheck /></el-icon></div>
|
||
<div class="kpi-card__main">
|
||
<div class="kpi-label">状态</div>
|
||
<div class="kpi-value">
|
||
<el-tag :type="applicationStatusTagType(applicationDetail.applicationStatus)" size="small">
|
||
{{ applicationStatusLabel(applicationDetail.applicationStatus) }}
|
||
</el-tag>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="kpi-card">
|
||
<div class="kpi-card__icon"><el-icon><User /></el-icon></div>
|
||
<div class="kpi-card__main">
|
||
<div class="kpi-label">渠道名称</div>
|
||
<div class="kpi-value">{{ displayValue(applicationDetail.channelName) }}</div>
|
||
</div>
|
||
</div>
|
||
<div class="kpi-card">
|
||
<div class="kpi-card__icon"><el-icon><Calendar /></el-icon></div>
|
||
<div class="kpi-card__main">
|
||
<div class="kpi-label">创建时间</div>
|
||
<div class="kpi-value">{{ displayValue(applicationDetail.createTime) }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 原 el-tabs 内容迁移到这里 -->
|
||
<div id="tab-basic" class="form-tab-pane">
|
||
<!-- 申请单信息(只读,用 data-grid 替代 el-descriptions) -->
|
||
<div class="section-card section-card--white">
|
||
<div class="section-title"><h3>申请单信息</h3></div>
|
||
<div class="data-grid data-grid--4">
|
||
<div class="data-item"><div class="data-label">申请单号</div><div class="data-value">{{ displayValue(applicationDetail.applicationNo) }}</div></div>
|
||
<div class="data-item"><div class="data-label">渠道名称</div><div class="data-value">{{ displayValue(applicationDetail.channelName) }}</div></div>
|
||
<div class="data-item"><div class="data-label">服务订单名称</div><div class="data-value">{{ displayValue(applicationDetail.serviceOrderNo) }}</div></div>
|
||
<div class="data-item"><div class="data-label">状态</div><div class="data-value"><el-tag :type="applicationStatusTagType(applicationDetail.applicationStatus)" size="small">{{ applicationStatusLabel(applicationDetail.applicationStatus) }}</el-tag></div></div>
|
||
<div class="data-item"><div class="data-label">创建时间</div><div class="data-value">{{ displayValue(applicationDetail.createTime) }}</div></div>
|
||
<div class="data-item"><div class="data-label">备注</div><div class="data-value">{{ displayValue(applicationDetail.remark) }}</div></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 患者信息(可编辑表单,保留原 form-grid-2 布局) -->
|
||
<!-- 直接复制原 ApplicationDetail.vue 中 Tab1 的患者信息部分 -->
|
||
</div>
|
||
|
||
<!-- 其余5个Tab内容照搬原文件,每个用 form-tab-pane 包裹 -->
|
||
<!-- ... -->
|
||
</template>
|
||
</div>
|
||
|
||
<!-- 状态操作按钮(照抄原 header 中的按钮) -->
|
||
<template #header>
|
||
<div class="detail-dialog-header">
|
||
<div class="detail-dialog-header-left">
|
||
<span class="detail-dialog-title">申请单详情</span>
|
||
<el-tag v-if="applicationDetail.applicationStatus" :type="applicationStatusTagType(applicationDetail.applicationStatus)" size="large">
|
||
{{ applicationStatusLabel(applicationDetail.applicationStatus) }}
|
||
</el-tag>
|
||
</div>
|
||
<div class="detail-dialog-header-right">
|
||
<el-button v-if="applicationDetail.applicationStatus === 'dks'" class="btn-main" type="primary" @click="handleStart"><el-icon><VideoPlay /></el-icon>开始</el-button>
|
||
<el-button v-if="applicationDetail.applicationStatus === 'jxz'" class="btn-main" type="primary" @click="handleComplete"><el-icon><CircleCheck /></el-icon>完成</el-button>
|
||
<el-button v-if="applicationDetail.applicationStatus === 'dks' || applicationDetail.applicationStatus === 'jxz'" class="btn-op-item op-warn" type="danger" @click="handleVoid"><el-icon><CircleClose /></el-icon>作废</el-button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
<!-- 8个嵌套弹窗照搬 -->
|
||
</template>
|
||
```
|
||
|
||
2. **替换 `<script>` 的 props 接口**:
|
||
|
||
```typescript
|
||
defineOptions({ name: 'ApplicationDetailDialog' })
|
||
|
||
const props = defineProps<{
|
||
modelValue: boolean
|
||
applicationId: string
|
||
listRow?: any
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'update:modelValue', val: boolean): void
|
||
(e: 'status-changed'): void
|
||
}>()
|
||
```
|
||
|
||
3. **替换数据加载方式**:移除 `onMounted` + `route.query.id`,改为:
|
||
|
||
```typescript
|
||
// 移除
|
||
// const route = useRoute()
|
||
// onMounted(() => { loadApplicationDetail(route.query.id) })
|
||
|
||
// 新增
|
||
const detailTabs = [
|
||
{ key: 'basic', label: '基本信息' },
|
||
{ key: 'infusion', label: '输注方案' },
|
||
{ key: 'checkup', label: '体检信息' },
|
||
{ key: 'delivery', label: '交付信息' },
|
||
{ key: 'release', label: '放行审核' },
|
||
{ key: 'confirm', label: '应用确认' },
|
||
]
|
||
|
||
const activeTab = ref('basic')
|
||
|
||
const scrollToTab = (key: string) => {
|
||
activeTab.value = key
|
||
const el = document.getElementById(`tab-${key}`)
|
||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||
}
|
||
|
||
watch(() => props.applicationId, (newId) => {
|
||
if (newId && props.modelValue) {
|
||
loadApplicationDetail(newId)
|
||
}
|
||
}, { immediate: true })
|
||
```
|
||
|
||
4. **状态操作后发射事件**:在 `handleStart`、`handleComplete`、`handleVoid` 成功后追加 `emit('status-changed')`
|
||
|
||
5. **新增图标 import**:`VideoPlay`, `CircleCheck`, `CircleClose`, `Document`, `Calendar` 等
|
||
|
||
- [ ] **Step 2: 照抄 CSS 样式**
|
||
|
||
从 `ProtocolViewDialog.vue` 的 `<style scoped>` 中复制以下 CSS 块到 `ApplicationDetailDialog.vue`:
|
||
|
||
- `.kpi-strip` / `.kpi-card` / `.kpi-card__icon` / `.kpi-card__main` / `.kpi-label` / `.kpi-value`(L2073-2143)
|
||
- `.data-grid` / `--2` / `--3` / `--4`(L2197-2212)
|
||
- `.data-item` / `.data-label` / `.data-value`(L2214-2238)
|
||
- `.section-card` / `--white` / `--green`(L2146-2168)
|
||
- `.section-title` + `::before` + `h3`(L2170-2194)
|
||
|
||
从 `ProtocolFormDialog.vue` 的 `<style scoped>` 中复制:
|
||
|
||
- `.detail-tabs` / `.detail-tab`(L2878-2912)
|
||
- `.form-tab-pane`(L2917-2924)
|
||
- `.form-grid` / `--col2` / `--col4` / `.form-col-span-2`(L2995-3023)
|
||
|
||
新增弹窗 header 样式:
|
||
|
||
```scss
|
||
.detail-dialog-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
width: 100%;
|
||
}
|
||
|
||
.detail-dialog-header-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.detail-dialog-title {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: #1e293b;
|
||
}
|
||
|
||
.detail-dialog-header-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.application-detail-body {
|
||
background: #f5f7fa;
|
||
overflow: visible;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 验证无编译错误**
|
||
|
||
Run: `cd web-admin && pnpm build:typecheck 2>&1 | head -50`
|
||
Expected: 无与 ApplicationDetailDialog.vue 相关的类型错误
|
||
|
||
---
|
||
|
||
## Task 2: 改造列表页 index.vue — 引入弹窗 + 补按钮图标
|
||
|
||
**Files:**
|
||
- Modify: `src/views/pilot-service/pilot-application/index.vue`
|
||
|
||
- [ ] **Step 1: 引入弹窗组件,替换路由跳转逻辑**
|
||
|
||
在 `<script>` 区域:
|
||
|
||
1. 移除 `const router = useRouter()`(如果其他地方还用到 router 则保留)
|
||
|
||
2. 新增弹窗状态变量和事件处理:
|
||
|
||
```typescript
|
||
import ApplicationDetailDialog from './ApplicationDetailDialog.vue'
|
||
|
||
const detailDialogVisible = ref(false)
|
||
const detailApplicationId = ref('')
|
||
|
||
const handleView = (row: any) => {
|
||
detailApplicationId.value = row.id
|
||
detailDialogVisible.value = true
|
||
}
|
||
|
||
const handleEdit = (row: any) => {
|
||
detailApplicationId.value = row.id
|
||
detailDialogVisible.value = true
|
||
}
|
||
|
||
// handleSubmit 中:创建成功后打开弹窗而非跳转路由
|
||
// 将 router.push({ name: 'PilotApplicationDetail', ... }) 替换为:
|
||
// detailApplicationId.value = newId
|
||
// detailDialogVisible.value = true
|
||
|
||
const onDetailStatusChanged = () => {
|
||
loadData()
|
||
}
|
||
```
|
||
|
||
3. 在 `<template>` 底部(新增申请单弹窗之后)添加弹窗组件:
|
||
|
||
```vue
|
||
<ApplicationDetailDialog
|
||
v-model="detailDialogVisible"
|
||
:application-id="detailApplicationId"
|
||
@status-changed="onDetailStatusChanged"
|
||
/>
|
||
```
|
||
|
||
- [ ] **Step 2: 补充操作列按钮的 el-icon**
|
||
|
||
在 `<template>` 操作列区域,为所有 `el-button` 补充 `<el-icon>` 前缀:
|
||
|
||
```vue
|
||
<!-- 查看 -->
|
||
<el-button class="btn-op-item primary" @click="handleView(row)">
|
||
<el-icon><View /></el-icon>查看
|
||
</el-button>
|
||
|
||
<!-- 编辑 -->
|
||
<el-button v-if="row.applicationStatus === 'dks'" class="btn-op-item" @click="handleEdit(row)">
|
||
<el-icon><Edit /></el-icon>编辑
|
||
</el-button>
|
||
|
||
<!-- 删除(popconfirm 内) -->
|
||
<template #reference>
|
||
<el-button class="btn-op-item op-warn"><el-icon><Delete /></el-icon>删除</el-button>
|
||
</template>
|
||
|
||
<!-- 开始 -->
|
||
<el-button v-if="row.applicationStatus === 'dks'" class="btn-op-item primary" type="primary" @click="handleStart(row)">
|
||
<el-icon><VideoPlay /></el-icon>开始
|
||
</el-button>
|
||
|
||
<!-- 完成 -->
|
||
<el-button v-if="row.applicationStatus === 'jxz'" class="btn-op-item primary" type="primary" @click="handleComplete(row)">
|
||
<el-icon><CircleCheck /></el-icon>完成
|
||
</el-button>
|
||
|
||
<!-- 作废 -->
|
||
<el-button v-if="..." class="btn-op-item op-warn" type="danger" @click="handleVoid(row)">
|
||
<el-icon><CircleClose /></el-icon>作废
|
||
</el-button>
|
||
```
|
||
|
||
更新 import 语句:
|
||
|
||
```typescript
|
||
import {
|
||
Plus, Search, RefreshLeft, Close, Check,
|
||
View, Edit, Delete, VideoPlay, CircleCheck, CircleClose
|
||
} from '@element-plus/icons-vue'
|
||
```
|
||
|
||
- [ ] **Step 3: 验证列表页无编译错误**
|
||
|
||
Run: `cd web-admin && pnpm build:typecheck 2>&1 | head -50`
|
||
Expected: 无与 index.vue 相关的类型错误
|
||
|
||
---
|
||
|
||
## Task 3: 清理路由 + 删除旧详情页
|
||
|
||
**Files:**
|
||
- Modify: `src/router/modules/sqxp.ts`
|
||
- Delete: `src/views/pilot-service/pilot-application/ApplicationDetail.vue`
|
||
|
||
- [ ] **Step 1: 删除 PilotApplicationDetail 路由定义**
|
||
|
||
在 `src/router/modules/sqxp.ts` 第650-656行,删除:
|
||
|
||
```typescript
|
||
{
|
||
id: '80000000000000000000000000000011',
|
||
name: 'PilotApplicationDetail',
|
||
path: 'pilot-application/detail',
|
||
component: '/pilot-service/pilot-application/ApplicationDetail',
|
||
meta: { title: '申请单详情', hidden: true }
|
||
}
|
||
```
|
||
|
||
注意保留上一行的逗号处理——删除该对象后,`PilotApplication` 路由对象后不应有尾随逗号。
|
||
|
||
- [ ] **Step 2: 删除 ApplicationDetail.vue**
|
||
|
||
```bash
|
||
rm src/views/pilot-service/pilot-application/ApplicationDetail.vue
|
||
```
|
||
|
||
- [ ] **Step 3: 全局搜索确认无其他引用**
|
||
|
||
Run: `grep -r "PilotApplicationDetail\|ApplicationDetail" web-admin/src/ --include="*.vue" --include="*.ts"`
|
||
Expected: 0 结果(所有引用已清理)
|
||
|
||
---
|
||
|
||
## Task 4: 弹窗内嵌套弹窗按钮图标补充
|
||
|
||
**Files:**
|
||
- Modify: `src/views/pilot-service/pilot-application/ApplicationDetailDialog.vue`
|
||
|
||
- [ ] **Step 1: 检查并补充弹窗内所有操作按钮的 el-icon**
|
||
|
||
在 `ApplicationDetailDialog.vue` 中,搜索所有 `el-button` 标签(不含 `el-icon` 子元素的),按 openspec 规范补充图标:
|
||
|
||
- "新增输注方案" → `Plus`
|
||
- "手动添加计划" → `Plus`
|
||
- "选择客户" → `User`
|
||
- "保存患者信息" → `Check`
|
||
- 各 Tab 中的"编辑" → `Edit`
|
||
- 各 Tab 中的"删除" → `Delete`
|
||
- 嵌套弹窗 footer 的"取消" → `Close`,"确定" → `Check`
|
||
- 其他操作按钮逐一检查
|
||
|
||
确保所有图标已在 `<script>` 中从 `@element-plus/icons-vue` import。
|
||
|
||
- [ ] **Step 2: 验证无遗漏**
|
||
|
||
Run: `grep -n '<el-button' web-admin/src/views/pilot-service/pilot-application/ApplicationDetailDialog.vue | head -50`
|
||
Expected: 手动检查每个 el-button 是否包含 `<el-icon>` 子元素
|
||
|
||
---
|
||
|
||
## Task 5: 端到端验证
|
||
|
||
**Files:** 无新增修改
|
||
|
||
- [ ] **Step 1: 构建检查**
|
||
|
||
Run: `cd web-admin && pnpm build 2>&1 | tail -20`
|
||
Expected: 构建成功,无错误
|
||
|
||
- [ ] **Step 2: Lint 检查**
|
||
|
||
Run: `cd web-admin && pnpm lint 2>&1 | tail -30`
|
||
Expected: 无 ESLint 错误(或仅预存在的无关错误)
|
||
|
||
- [ ] **Step 3: 更新知识图谱**
|
||
|
||
Run: `./scripts/update-graph.sh frontend`
|
||
Expected: 前端知识图谱更新成功
|
||
|
||
---
|
||
|
||
## Self-Review
|
||
|
||
### 1. Spec Coverage
|
||
|
||
| Spec Section | Task |
|
||
|---|---|
|
||
| 3.1 照抄清单 A1-A5 (ViewDialog) | Task 1 (弹窗壳层+CSS) |
|
||
| 3.1 照抄清单 B1-B4 (FormDialog) | Task 1 (表单CSS) |
|
||
| 3.1 照抄清单 C1-C4 (公共) | Task 1 (detail-tabs, section-card) |
|
||
| 3.2 弹窗组件接口 | Task 1 (defineProps/defineEmits) |
|
||
| 3.3 弹窗壳层配置 | Task 1 (el-dialog 1600px) |
|
||
| 3.4 顶部区域改造 | Task 1 (自定义 #header) |
|
||
| 3.5 正文结构 | Task 1 (KPI + data-grid + form-tab-pane) |
|
||
| 3.6 数据加载变更 | Task 1 (watch) |
|
||
| 3.7 列表页改造 | Task 2 (引入弹窗+替换路由) |
|
||
| 4.1 操作列按钮图标 | Task 2 (补充 el-icon) |
|
||
| 4.2 弹窗内按钮图标 | Task 4 |
|
||
| 5. 路由清理 | Task 3 |
|
||
|
||
**Gap check:** 无遗漏。
|
||
|
||
### 2. Placeholder Scan
|
||
|
||
无 TBD/TODO/占位符。
|
||
|
||
### 3. Type Consistency
|
||
|
||
- `ApplicationDetailDialog` props: `modelValue: boolean`, `applicationId: string` — 与 `index.vue` 中的使用 `v-model="detailDialogVisible"` 和 `:application-id="detailApplicationId"` 一致。
|
||
- Event `status-changed` — 与 `@status-changed="onDetailStatusChanged"` 一致。
|