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 CSSsrc/views/cell-storage/protocol/ProtocolFormDialog.vue — form-grid、upload-card CSSFiles:
src/views/pilot-service/pilot-application/ApplicationDetailDialog.vuesrc/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,然后改造:
<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>
<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
}>()
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 })
状态操作后发射事件:在 handleStart、handleComplete、handleVoid 成功后追加 emit('status-changed')
新增图标 import:VideoPlay, CircleCheck, CircleClose, Document, Calendar 等
从 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;
}
Run: cd web-admin && pnpm build:typecheck 2>&1 | head -50
Expected: 无与 ApplicationDetailDialog.vue 相关的类型错误
Files:
Modify: src/views/pilot-service/pilot-application/index.vue
[ ] Step 1: 引入弹窗组件,替换路由跳转逻辑
在 <script> 区域:
移除 const router = useRouter()(如果其他地方还用到 router 则保留)
新增弹窗状态变量和事件处理:
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()
}
<template> 底部(新增申请单弹窗之后)添加弹窗组件:<ApplicationDetailDialog
v-model="detailDialogVisible"
:application-id="detailApplicationId"
@status-changed="onDetailStatusChanged"
/>
在 <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'
Run: cd web-admin && pnpm build:typecheck 2>&1 | head -50
Expected: 无与 index.vue 相关的类型错误
Files:
src/router/modules/sqxp.tsDelete: 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 路由对象后不应有尾随逗号。
rm src/views/pilot-service/pilot-application/ApplicationDetail.vue
Run: grep -r "PilotApplicationDetail\|ApplicationDetail" web-admin/src/ --include="*.vue" --include="*.ts"
Expected: 0 结果(所有引用已清理)
Files:
Modify: src/views/pilot-service/pilot-application/ApplicationDetailDialog.vue
[ ] Step 1: 检查并补充弹窗内所有操作按钮的 el-icon
在 ApplicationDetailDialog.vue 中,搜索所有 el-button 标签(不含 el-icon 子元素的),按 openspec 规范补充图标:
PlusPlusUserCheckEditDeleteClose,"确定" → Check确保所有图标已在 <script> 中从 @element-plus/icons-vue import。
Run: grep -n '<el-button' web-admin/src/views/pilot-service/pilot-application/ApplicationDetailDialog.vue | head -50
Expected: 手动检查每个 el-button 是否包含 <el-icon> 子元素
Files: 无新增修改
Run: cd web-admin && pnpm build 2>&1 | tail -20
Expected: 构建成功,无错误
Run: cd web-admin && pnpm lint 2>&1 | tail -30
Expected: 无 ESLint 错误(或仅预存在的无关错误)
Run: ./scripts/update-graph.sh frontend
Expected: 前端知识图谱更新成功
| 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: 无遗漏。
无 TBD/TODO/占位符。
ApplicationDetailDialog props: modelValue: boolean, applicationId: string — 与 index.vue 中的使用 v-model="detailDialogVisible" 和 :application-id="detailApplicationId" 一致。status-changed — 与 @status-changed="onDetailStatusChanged" 一致。