2025-05-25-pilot-application-dialog-refactor.md 17 KB

中试服务申请单 — 详情页改弹窗 + 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 弹窗壳:
<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>
  1. 替换 <script> 的 props 接口:
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
}>()
  1. 替换数据加载方式:移除 onMounted + route.query.id,改为:
// 移除
// 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 })
  1. 状态操作后发射事件:在 handleStart、handleComplete、handleVoid 成功后追加 emit('status-changed')

  2. 新增图标 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 样式:

.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. 新增弹窗状态变量和事件处理:

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()
}
  1. 在 <template> 底部(新增申请单弹窗之后)添加弹窗组件:
<ApplicationDetailDialog
  v-model="detailDialogVisible"
  :application-id="detailApplicationId"
  @status-changed="onDetailStatusChanged"
/>
  • Step 2: 补充操作列按钮的 el-icon

在 <template> 操作列区域,为所有 el-button 补充 <el-icon> 前缀:

<!-- 查看 -->
<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 语句:

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行,删除:

{
  id: '80000000000000000000000000000011',
  name: 'PilotApplicationDetail',
  path: 'pilot-application/detail',
  component: '/pilot-service/pilot-application/ApplicationDetail',
  meta: { title: '申请单详情', hidden: true }
}

注意保留上一行的逗号处理——删除该对象后,PilotApplication 路由对象后不应有尾随逗号。

  • Step 2: 删除 ApplicationDetail.vue
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" 一致。