zlwu 5 месяцев назад
Родитель
Сommit
239d0a03d7
24 измененных файлов с 928 добавлено и 402 удалено
  1. 1 1
      backend/app.pid
  2. 4 0
      backend/app/api/outbound_release_review.py
  3. 22 0
      backend/app/crud/clinical_payment.py
  4. 21 1
      backend/app/crud/outbound_release_review.py
  5. 15 0
      backend/app/crud/pilot_infusion_schedule_mgmt.py
  6. 2 0
      backend/app/schemas/outbound_release_review.py
  7. 1 1
      web-admin/graphify-out/GRAPH_REPORT.md
  8. 2 2
      web-admin/graphify-out/manifest.json
  9. 2 0
      web-admin/src/api/outbound-release-review.ts
  10. 102 14
      web-admin/src/components/business/outbound-material-form-sections/index.vue
  11. 1 0
      web-admin/src/graphify-out/cache/ast/aaede6cd859915f0bd26e7cbfb62b7f6b2fd4635e8b761d45a5fc1963c3aaa42.json
  12. 0 0
      web-admin/src/graphify-out/cache/ast/b29f358a9b8eed3851abe87fef3209b1d0d9a9a0499156988b074bb6386f8a6a.json
  13. 27 6
      web-admin/src/utils/outbound-qc-pilot-view-sections.ts
  14. 102 14
      web-admin/src/views/pilot-service/outbound-release-review/OutboundReleaseMaterialDialog.vue
  15. 37 3
      web-admin/src/views/pilot-service/outbound-release-review/index.vue
  16. 22 9
      web-admin/src/views/pilot-service/pilot-application/InfusionScheduleTab.vue
  17. 3 4
      web-admin/src/views/production/production-work-order/StepDetailPanel.vue
  18. 31 10
      web-admin/src/views/production/production-work-order/TaskExecutionDialog.vue
  19. 2 2
      web-admin/src/views/production/production-work-order/TaskInputEnhancedTable.vue
  20. 6 1
      web-admin/src/views/production/production-work-order/TaskOutputList.vue
  21. 230 135
      web-admin/src/views/production/production-work-order/WorkOrderTaskSubList.vue
  22. 31 71
      web-admin/src/views/production/production-work-order/index.vue
  23. 28 2
      web-admin/src/views/quality-inspection/qc-work-order/OutboundQcViewDialog.vue
  24. 236 126
      web-admin/src/views/quality-inspection/qc-work-order/QcWorkOrderTaskSubList.vue

+ 1 - 1
backend/app.pid

@@ -1 +1 @@
-1098
+69671

+ 4 - 0
backend/app/api/outbound_release_review.py

@@ -45,6 +45,8 @@ async def list_pilot_release_reviews(
     scheduleStatus: Optional[str] = None,
     qcStatus: Optional[str] = None,
     bizType: Optional[str] = None,
+    infusionTimeStart: Optional[str] = None,
+    infusionTimeEnd: Optional[str] = None,
     db: Session = Depends(get_db),
     user: dict = Depends(require_menu_code("biz:outboundReleaseReview:list")),
 ):
@@ -58,6 +60,8 @@ async def list_pilot_release_reviews(
             schedule_status=scheduleStatus,
             qc_status=qcStatus,
             biz_type=bizType,
+            infusion_time_start=infusionTimeStart,
+            infusion_time_end=infusionTimeEnd,
             page=page,
             page_size=pageSize,
             user_info=user,

+ 22 - 0
backend/app/crud/clinical_payment.py

@@ -128,6 +128,28 @@ def map_payments_by_schedule_id(
     return {row.infusion_schedule_id: _enrich_payment(db, row) for row in rows}
 
 
+def map_payments_by_schedule_ids(
+    db: Session,
+    *,
+    schedule_ids: List[str],
+    tenant_no: str,
+) -> Dict[str, Dict[str, Any]]:
+    """按输注计划 ID 批量索引缴费记录(列表页展示用,不做关联 enrich)"""
+    unique_ids = [sid for sid in {sid for sid in schedule_ids if sid}]
+    if not unique_ids:
+        return {}
+    rows = (
+        db.query(BizPilotPayment)
+        .filter(
+            BizPilotPayment.infusion_schedule_id.in_(unique_ids),
+            BizPilotPayment.tenant_no == tenant_no,
+            BizPilotPayment.del_flag == 0,
+        )
+        .all()
+    )
+    return {row.infusion_schedule_id: _row_to_dict(row) for row in rows}
+
+
 # ─── create (auto) ────────────────────────────────────────
 
 

+ 21 - 1
backend/app/crud/outbound_release_review.py

@@ -5,7 +5,7 @@ import math
 from datetime import datetime
 from typing import Any, Dict, List, Optional
 
-from sqlalchemy import func, or_
+from sqlalchemy import and_, func, or_
 from sqlalchemy.orm import Session
 
 from app.core.utils import generate_uuid
@@ -118,6 +118,8 @@ def list_reviews_paginated(
     schedule_status: Optional[str] = None,
     qc_status: Optional[str] = None,
     biz_type: Optional[str] = None,
+    infusion_time_start: Optional[str] = None,
+    infusion_time_end: Optional[str] = None,
     page: int = 1,
     page_size: int = 10,
     user_info=None,
@@ -212,6 +214,24 @@ def list_reviews_paginated(
     if qs:
         q0 = q0.filter(BizPilotInfusionSchedule.qc_status == qs)
 
+    # ── 输注时间范围 ──
+    time_bounds = []
+    if (infusion_time_start or "").strip():
+        try:
+            start_dt = datetime.strptime(infusion_time_start.strip()[:10], "%Y-%m-%d")
+            time_bounds.append(BizPilotInfusionSchedule.infusion_time >= start_dt)
+        except ValueError:
+            pass
+    if (infusion_time_end or "").strip():
+        try:
+            end_dt = datetime.strptime(infusion_time_end.strip()[:10], "%Y-%m-%d")
+            end_dt = end_dt.replace(hour=23, minute=59, second=59)
+            time_bounds.append(BizPilotInfusionSchedule.infusion_time <= end_dt)
+        except ValueError:
+            pass
+    if time_bounds:
+        q0 = q0.filter(or_(and_(*time_bounds), BizPilotInfusionSchedule.infusion_time.is_(None)))
+
     # ── review_conclusion 过滤 ──
     rc = (review_conclusion or "").strip()
     if rc == "pending":

+ 15 - 0
backend/app/crud/pilot_infusion_schedule_mgmt.py

@@ -385,6 +385,21 @@ def list_infusion_schedules_mgmt(
             )
         )
 
+    schedule_ids = [item["id"] for item in items if item.get("id")]
+    if schedule_ids:
+        from app.crud import clinical_payment
+
+        payment_map = clinical_payment.map_payments_by_schedule_ids(
+            db, schedule_ids=schedule_ids, tenant_no=tenant_no
+        )
+        for item in items:
+            payment = payment_map.get(item["id"])
+            if not payment:
+                continue
+            item["payment"] = payment
+            item["amount"] = payment.get("amount")
+            item["payStatus"] = payment.get("payStatus")
+
     return {
         "list": items,
         "pagination": {"page": page, "pageSize": page_size, "total": total},

+ 2 - 0
backend/app/schemas/outbound_release_review.py

@@ -12,6 +12,8 @@ class OutboundReviewListParams(BaseModel):
     reviewConclusion: Optional[str] = None
     scheduleStatus: Optional[str] = None
     qcStatus: Optional[str] = None
+    infusionTimeStart: Optional[str] = None
+    infusionTimeEnd: Optional[str] = None
 
 
 class OutboundReviewSubmitBody(BaseModel):

+ 1 - 1
web-admin/graphify-out/GRAPH_REPORT.md

@@ -1,7 +1,7 @@
 # Graph Report - src  (2026-05-28)
 
 ## Corpus Check
-- Large corpus: 672 files · ~417,392 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
+- Large corpus: 672 files · ~417,381 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
 
 ## Summary
 - 1934 nodes · 2494 edges · 50 communities detected

+ 2 - 2
web-admin/graphify-out/manifest.json

@@ -329,7 +329,7 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/process-category/index.vue": 1779864070.4019275,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/process-category/OutputTypeTab.vue": 1779864078.964701,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/index/index.vue": 1779525781.6949234,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/qc-work-order/index.vue": 1779965464.3573413,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/qc-work-order/index.vue": 1779965568.1579607,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/qc-work-order/QcWorkOrderTaskSubList.vue": 1779948870.746249,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/qc-work-order/OutboundQcViewDialog.vue": 1779889633.846836,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/sample-reception-check-item/index.vue": 1779857055.2939715,
@@ -339,7 +339,7 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/qc-material/QcMaterialUsageTab.vue": 1779928630.307365,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/sample-reception/index.vue": 1779884110.7197206,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/qc-task/index.vue": 1779926469.2154074,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/storage-release-review/index.vue": 1779857044.9472554,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/storage-release-review/index.vue": 1779966532.6566818,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/storage-release-review/StorageReleaseReviewDialog.vue": 1779701230.688804,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/storage-release-review/StorageReleaseViewDialog.vue": 1779790362.6831455,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/qc-material-usage/index.vue": 1779864358.234948,

+ 2 - 0
web-admin/src/api/outbound-release-review.ts

@@ -160,6 +160,8 @@ export function fetchOutboundReleaseReviewList(params?: {
   keyword?: string
   /** pilot_service | clinical_application */
   bizType?: string
+  infusionTimeStart?: string
+  infusionTimeEnd?: string
 }) {
   return request.get<PageList<OutboundReleaseReview>>({
     url: '/web/admin/pilot-release-reviews',

+ 102 - 14
web-admin/src/components/business/outbound-material-form-sections/index.vue

@@ -185,31 +185,58 @@
                   v-for="(att, attIdx) in row.attachments"
                   :key="attIdx"
                   class="paper-att-card"
+                  :class="{
+                    'paper-att-card--image': isImageFile(att),
+                    'paper-att-card--video': isVideoFile(att)
+                  }"
+                  :title="att.fileName"
                 >
-                  <el-icon class="paper-att-card__icon"><Document /></el-icon>
-                  <span class="paper-att-card__name" :title="att.fileName">{{
-                    att.fileName || '附件'
-                  }}</span>
+                  <div
+                    class="paper-att-card__preview"
+                    :class="{
+                      'is-clickable': isImageFile(att) || isVideoFile(att)
+                    }"
+                    @click="
+                      (isImageFile(att) || isVideoFile(att)) && handlePreviewPaperAttachment(att)
+                    "
+                  >
+                    <img
+                      v-if="isImageFile(att)"
+                      :src="fullFileUrl(att.attachmentUrl)"
+                      :alt="att.fileName || '附件'"
+                    />
+                    <template v-else-if="isVideoFile(att)">
+                      <div class="paper-att-card__type-icon paper-att-card__type-icon--video">
+                        <el-icon><VideoPlay /></el-icon>
+                      </div>
+                    </template>
+                    <template v-else>
+                      <el-icon class="paper-att-card__type-icon paper-att-card__type-icon--doc">
+                        <Document />
+                      </el-icon>
+                      <span class="paper-att-card__name">{{ att.fileName || '附件' }}</span>
+                    </template>
+                  </div>
                   <div class="paper-att-card__actions">
                     <el-icon
-                      v-if="isImageFile(att)"
+                      v-if="isImageFile(att) || isVideoFile(att)"
                       class="paper-att-card__action"
                       title="预览"
-                      @click="handlePreviewPaperAttachment(att)"
+                      @click.stop="handlePreviewPaperAttachment(att)"
                     >
                       <View />
                     </el-icon>
                     <el-icon
                       class="paper-att-card__action"
                       title="下载"
-                      @click="handleDownloadPaperAttachment(att)"
+                      @click.stop="handleDownloadPaperAttachment(att)"
                     >
                       <Download />
                     </el-icon>
                     <el-icon
                       class="paper-att-card__action paper-att-card__action--danger"
                       title="删除"
-                      @click="removePaperAttachment($index, attIdx)"
+                      @click.stop="removePaperAttachment($index, attIdx)"
                     >
                       <Delete />
                     </el-icon>
@@ -357,7 +384,8 @@
     Search,
     Document,
     Download,
-    View
+    View,
+    VideoPlay
   } from '@element-plus/icons-vue'
   import { fetchUploadAttachment } from '@/api/business'
   import { fetchCellProductList } from '@/api/cell-product'
@@ -874,6 +902,11 @@
     display: flex;
     flex-wrap: wrap;
     gap: 8px;
+
+    :deep(.el-upload) {
+      display: block;
+      line-height: normal;
+    }
   }
 
   .paper-att-card {
@@ -884,17 +917,62 @@
     background: #f8fafc;
     display: flex;
     flex-direction: column;
+    align-items: stretch;
+    justify-content: center;
+    padding: 0;
+    position: relative;
+    overflow: hidden;
+  }
+
+  .paper-att-card__preview {
+    flex: 1;
+    min-height: 0;
+    display: flex;
+    flex-direction: column;
     align-items: center;
     justify-content: center;
     padding: 6px 4px;
-    position: relative;
     overflow: hidden;
+
+    &.is-clickable {
+      cursor: pointer;
+    }
+
+    img {
+      width: 100%;
+      height: 100%;
+      object-fit: cover;
+      display: block;
+    }
   }
 
-  .paper-att-card__icon {
-    color: #0284c7;
-    font-size: 18px;
+  .paper-att-card--image,
+  .paper-att-card--video {
+    .paper-att-card__preview {
+      padding: 0;
+    }
+  }
+
+  .paper-att-card__type-icon {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    font-size: 22px;
     margin-bottom: 2px;
+
+    &--doc {
+      color: #0284c7;
+    }
+
+    &--video {
+      width: 36px;
+      height: 36px;
+      border-radius: 8px;
+      background: #ede9fe;
+      color: #7c3aed;
+      font-size: 20px;
+      margin-bottom: 4px;
+    }
   }
 
   .paper-att-card__name {
@@ -944,6 +1022,10 @@
   }
 
   .paper-att-card--trigger {
+    align-items: center;
+    justify-content: center;
+    gap: 4px;
+    padding: 6px 4px;
     border: 1px dashed #a7f3d0;
     background: transparent;
     cursor: pointer;
@@ -961,14 +1043,20 @@
   }
 
   .paper-att-card__trigger-icon {
+    display: flex;
+    align-items: center;
+    justify-content: center;
     font-size: 16px;
     color: #10b981;
-    margin-bottom: 2px;
+    margin: 0;
+    line-height: 1;
   }
 
   .paper-att-card--trigger span {
     font-size: 11px;
     color: #64748b;
+    line-height: 1.2;
+    text-align: center;
   }
 
   .preview-container {

+ 1 - 0
web-admin/src/graphify-out/cache/ast/aaede6cd859915f0bd26e7cbfb62b7f6b2fd4635e8b761d45a5fc1963c3aaa42.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_quality_inspection_storage_release_review_index_vue", "label": "index.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/storage-release-review/index.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
web-admin/src/graphify-out/cache/ast/b29f358a9b8eed3851abe87fef3209b1d0d9a9a0499156988b074bb6386f8a6a.json


+ 27 - 6
web-admin/src/utils/outbound-qc-pilot-view-sections.ts

@@ -10,21 +10,42 @@ import {
   qcStatusLabel,
   qcStatusTagType,
   scheduleStatusLabel,
-  scheduleStatusTagType
+  scheduleStatusTagType,
+  sexLabel
 } from '@/utils/status-tag-registry'
 
-/** 出库质检 + 中试服务:申请单 / 患者 / 输注方案 / 输注计划 / 产品明细 */
-export function buildOutboundPilotContextSections(detail: OutboundQcDetail): ViewSection[] {
+export type OutboundViewContextVariant = 'pilot_service' | 'clinical_application'
+
+const SECTION_TITLES: Record<
+  OutboundViewContextVariant,
+  { application: string; schedule: string }
+> = {
+  pilot_service: {
+    application: '中试服务申请单信息',
+    schedule: '本次输注计划信息'
+  },
+  clinical_application: {
+    application: '临床应用申请信息',
+    schedule: '临床应用输注计划信息'
+  }
+}
+
+/** 出库工单/质检:申请单 / 患者 / 输注方案 / 输注计划 / 产品明细(码值转中文标签) */
+export function buildOutboundPilotContextSections(
+  detail: OutboundQcDetail,
+  variant: OutboundViewContextVariant = 'pilot_service'
+): ViewSection[] {
   const app = detail.application
   const patient = detail.patient
   const plan = detail.infusionPlan
   const schedule = detail.infusionSchedule
   const products = detail.products || []
+  const titles = SECTION_TITLES[variant]
   const result: ViewSection[] = []
 
   if (app) {
     result.push({
-      title: '中试服务申请单信息',
+      title: titles.application,
       fields: [
         { label: '申请单号', value: app.applicationNo || '—' },
         { label: '申请单名称', value: app.applicationName || '—' },
@@ -43,7 +64,7 @@ export function buildOutboundPilotContextSections(detail: OutboundQcDetail): Vie
       title: '患者信息',
       fields: [
         { label: '患者姓名', value: patient.patientName || '—' },
-        { label: '性别', value: patient.sex || '—' },
+        { label: '性别', value: sexLabel(String(patient.sex ?? '')) || '—' },
         { label: '年龄', value: patient.age || '—' },
         { label: '联系电话', value: patient.phoneNo || '—' },
         {
@@ -98,7 +119,7 @@ export function buildOutboundPilotContextSections(detail: OutboundQcDetail): Vie
     if (schedule.cancelReason) {
       scheduleFields.push({ label: '取消原因', value: schedule.cancelReason, span: true })
     }
-    result.push({ title: '本次输注计划信息', fields: scheduleFields })
+    result.push({ title: titles.schedule, fields: scheduleFields })
   }
 
   if (products.length) {

+ 102 - 14
web-admin/src/views/pilot-service/outbound-release-review/OutboundReleaseMaterialDialog.vue

@@ -212,31 +212,58 @@
                   v-for="(att, attIdx) in row.attachments"
                   :key="attIdx"
                   class="paper-att-card"
+                  :class="{
+                    'paper-att-card--image': isImageFile(att),
+                    'paper-att-card--video': isVideoFile(att)
+                  }"
+                  :title="att.fileName"
                 >
-                  <el-icon class="paper-att-card__icon"><Document /></el-icon>
-                  <span class="paper-att-card__name" :title="att.fileName">{{
-                    att.fileName || '附件'
-                  }}</span>
+                  <div
+                    class="paper-att-card__preview"
+                    :class="{
+                      'is-clickable': isImageFile(att) || isVideoFile(att)
+                    }"
+                    @click="
+                      (isImageFile(att) || isVideoFile(att)) && handlePreviewPaperAttachment(att)
+                    "
+                  >
+                    <img
+                      v-if="isImageFile(att)"
+                      :src="fullFileUrl(att.attachmentUrl)"
+                      :alt="att.fileName || '附件'"
+                    />
+                    <template v-else-if="isVideoFile(att)">
+                      <div class="paper-att-card__type-icon paper-att-card__type-icon--video">
+                        <el-icon><VideoPlay /></el-icon>
+                      </div>
+                    </template>
+                    <template v-else>
+                      <el-icon class="paper-att-card__type-icon paper-att-card__type-icon--doc">
+                        <Document />
+                      </el-icon>
+                      <span class="paper-att-card__name">{{ att.fileName || '附件' }}</span>
+                    </template>
+                  </div>
                   <div class="paper-att-card__actions">
                     <el-icon
-                      v-if="isImageFile(att)"
+                      v-if="isImageFile(att) || isVideoFile(att)"
                       class="paper-att-card__action"
                       title="预览"
-                      @click="handlePreviewPaperAttachment(att)"
+                      @click.stop="handlePreviewPaperAttachment(att)"
                     >
                       <View />
                     </el-icon>
                     <el-icon
                       class="paper-att-card__action"
                       title="下载"
-                      @click="handleDownloadPaperAttachment(att)"
+                      @click.stop="handleDownloadPaperAttachment(att)"
                     >
                       <Download />
                     </el-icon>
                     <el-icon
                       class="paper-att-card__action paper-att-card__action--danger"
                       title="删除"
-                      @click="removePaperAttachment($index, attIdx)"
+                      @click.stop="removePaperAttachment($index, attIdx)"
                     >
                       <Delete />
                     </el-icon>
@@ -398,7 +425,8 @@
     Search,
     Document,
     Download,
-    View
+    View,
+    VideoPlay
   } from '@element-plus/icons-vue'
   import { fetchUploadAttachment } from '@/api/business'
   import { fetchCellProductList } from '@/api/cell-product'
@@ -908,6 +936,11 @@
     display: flex;
     flex-wrap: wrap;
     gap: 8px;
+
+    :deep(.el-upload) {
+      display: block;
+      line-height: normal;
+    }
   }
 
   .paper-att-card {
@@ -918,17 +951,62 @@
     background: #f8fafc;
     display: flex;
     flex-direction: column;
+    align-items: stretch;
+    justify-content: center;
+    padding: 0;
+    position: relative;
+    overflow: hidden;
+  }
+
+  .paper-att-card__preview {
+    flex: 1;
+    min-height: 0;
+    display: flex;
+    flex-direction: column;
     align-items: center;
     justify-content: center;
     padding: 6px 4px;
-    position: relative;
     overflow: hidden;
+
+    &.is-clickable {
+      cursor: pointer;
+    }
+
+    img {
+      width: 100%;
+      height: 100%;
+      object-fit: cover;
+      display: block;
+    }
   }
 
-  .paper-att-card__icon {
-    color: #0284c7;
-    font-size: 18px;
+  .paper-att-card--image,
+  .paper-att-card--video {
+    .paper-att-card__preview {
+      padding: 0;
+    }
+  }
+
+  .paper-att-card__type-icon {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    font-size: 22px;
     margin-bottom: 2px;
+
+    &--doc {
+      color: #0284c7;
+    }
+
+    &--video {
+      width: 36px;
+      height: 36px;
+      border-radius: 8px;
+      background: #ede9fe;
+      color: #7c3aed;
+      font-size: 20px;
+      margin-bottom: 4px;
+    }
   }
 
   .paper-att-card__name {
@@ -978,6 +1056,10 @@
   }
 
   .paper-att-card--trigger {
+    align-items: center;
+    justify-content: center;
+    gap: 4px;
+    padding: 6px 4px;
     border: 1px dashed #a7f3d0;
     background: transparent;
     cursor: pointer;
@@ -995,14 +1077,20 @@
   }
 
   .paper-att-card__trigger-icon {
+    display: flex;
+    align-items: center;
+    justify-content: center;
     font-size: 16px;
     color: #10b981;
-    margin-bottom: 2px;
+    margin: 0;
+    line-height: 1;
   }
 
   .paper-att-card--trigger span {
     font-size: 11px;
     color: #64748b;
+    line-height: 1.2;
+    text-align: center;
   }
 
   .preview-container {

+ 37 - 3
web-admin/src/views/pilot-service/outbound-release-review/index.vue

@@ -33,9 +33,22 @@
                 />
               </el-select>
             </el-form-item>
+            <el-form-item label="输注时间">
+              <el-date-picker
+                v-model="searchForm.infusionTimeRange"
+                type="daterange"
+                range-separator="至"
+                start-placeholder="开始日期"
+                end-placeholder="结束日期"
+                value-format="YYYY-MM-DD"
+                style="width: 100%"
+              />
+            </el-form-item>
           </div>
           <div class="filter-actions-row">
-            <span class="filter-hint">支持按申请单号、患者姓名搜索。</span>
+            <span class="filter-hint"
+              >支持按申请单号、患者姓名搜索;输注时间默认今天前 15 天至后 15 天。</span
+            >
             <div class="filter-actions-end">
               <el-button class="btn-ghost" @click="handleReset">
                 <el-icon><RefreshLeft /></el-icon>重置
@@ -267,9 +280,27 @@
     { value: 'btg', label: '不通过' }
   ]
 
+  function formatLocalDate(d: Date) {
+    const y = d.getFullYear()
+    const m = String(d.getMonth() + 1).padStart(2, '0')
+    const day = String(d.getDate()).padStart(2, '0')
+    return `${y}-${m}-${day}`
+  }
+
+  /** 输注时间默认:今天前 15 天 ~ 今天后 15 天 */
+  function getDefaultInfusionTimeRange(): [string, string] {
+    const today = new Date()
+    const start = new Date(today)
+    start.setDate(start.getDate() - 15)
+    const end = new Date(today)
+    end.setDate(end.getDate() + 15)
+    return [formatLocalDate(start), formatLocalDate(end)]
+  }
+
   const searchForm = reactive({
     reviewConclusion: '',
-    keyword: ''
+    keyword: '',
+    infusionTimeRange: getDefaultInfusionTimeRange() as [string, string]
   })
   const tableData = ref<OutboundReleaseReview[]>([])
   const selectedRows = ref<OutboundReleaseReview[]>([])
@@ -290,7 +321,9 @@
         pageSize: pagination.pageSize,
         reviewConclusion: searchForm.reviewConclusion || undefined,
         keyword: searchForm.keyword || undefined,
-        bizType: effectiveBizType.value
+        bizType: effectiveBizType.value,
+        infusionTimeStart: searchForm.infusionTimeRange?.[0] || undefined,
+        infusionTimeEnd: searchForm.infusionTimeRange?.[1] || undefined
       })
       tableData.value = res.list || []
       pagination.total = res.total ?? 0
@@ -307,6 +340,7 @@
   function handleReset() {
     searchForm.reviewConclusion = ''
     searchForm.keyword = ''
+    searchForm.infusionTimeRange = getDefaultInfusionTimeRange()
     pagination.page = 1
     loadData()
   }

+ 22 - 9
web-admin/src/views/pilot-service/pilot-application/InfusionScheduleTab.vue

@@ -199,10 +199,10 @@
           >
             <template #default="{ row }">
               {{
-                row.amount
+                row.amount != null && row.amount !== ''
                   ? '¥' +
                     Number(row.amount).toLocaleString('zh-CN', { minimumFractionDigits: 2 })
-                  : '-'
+                  : '—'
               }}
             </template>
           </el-table-column>
@@ -215,13 +215,13 @@
           >
             <template #default="{ row }">
               <el-tag
-                v-if="row.paymentStatus"
-                :type="payStatusTagType(row.paymentStatus)"
+                v-if="row.payStatus"
+                :type="payStatusTagType(row.payStatus)"
                 size="small"
               >
-                {{ payStatusLabel(row.paymentStatus) }}
+                {{ payStatusLabel(row.payStatus) }}
               </el-tag>
-              <span v-else>-</span>
+              <span v-else>—</span>
             </template>
           </el-table-column>
           <el-table-column
@@ -552,10 +552,21 @@
   }
 
   const buildCompleteConfirmMessage = (row: any) => {
-    const orderTypeLabel = channelServiceOrderTypeLabel(row.orderType)
     const patient = row.patientName ? `患者「${row.patientName}」` : '该患者'
+    const pendingConfirmHint =
+      getInfusionStatus(row) === 'dtz' ? '当前为待输注,系统将先记录用户确认再标记完成。' : ''
+
+    if (isClinical.value) {
+      const paymentHint =
+        '临床应用不在此扣减渠道服务订单费用,缴费请在「缴费信息」中单独处理。'
+      return `${patient}的输注计划将标记为已完成。${pendingConfirmHint}${paymentHint}是否继续?`
+    }
+
+    const orderTypeLabel = channelServiceOrderTypeLabel(row.orderType)
     const statusHint =
-      getInfusionStatus(row) === 'dtz' ? '当前为待输注,系统将先记录用户确认再完成结算。' : ''
+      getInfusionStatus(row) === 'dtz'
+        ? '当前为待输注,系统将先记录用户确认再完成结算。'
+        : ''
     let settlementHint = '并触发关联服务订单的结算扣减。'
     if (row.orderType === 'yfk') {
       settlementHint = `关联服务订单类型为「${orderTypeLabel}」,系统将按渠道协议单价扣减服务订单账户余额。`
@@ -603,7 +614,9 @@
         scheduleId,
         infusionStatus: 'ywc'
       })
-      ElMessage.success('输注计划已完成,结算扣减已处理')
+      ElMessage.success(
+        isClinical.value ? '输注计划已完成' : '输注计划已完成,结算扣减已处理'
+      )
       loadData()
     } catch (e: any) {
       if (e !== 'cancel') {

+ 3 - 4
web-admin/src/views/production/production-work-order/StepDetailPanel.vue

@@ -14,8 +14,8 @@
             </el-tag>
             <el-button
               v-if="editor.selectedStep.value.stepStatus === 'dzx'"
-              class="btn-op-item primary"
-              size="small"
+              class="btn-main task-exec-toolbar-btn"
+              type="primary"
               :loading="editor.stepActionLoading.value"
               :disabled="editor.manualSaveLoading.value"
               @click="editor.handleStartStep(editor.selectedStep.value)"
@@ -35,9 +35,8 @@
             </el-button>
           </template>
           <el-button
-            class="btn-main"
+            class="btn-main task-exec-toolbar-btn"
             type="primary"
-            size="small"
             :loading="editor.manualSaveLoading.value"
             :disabled="editor.stepActionLoading.value"
             @click="editor.handleManualSave"

+ 31 - 10
web-admin/src/views/production/production-work-order/TaskExecutionDialog.vue

@@ -40,9 +40,8 @@
           <div v-if="!readonly" class="execution-header-actions">
             <el-button
               v-if="editor.taskDetail.value.taskStatus === 'dpz'"
-              class="btn-main"
+              class="btn-main task-exec-toolbar-btn"
               type="primary"
-              size="small"
               :loading="editor.taskActionLoading.value"
               @click="editor.handleStartTask"
             >
@@ -53,9 +52,8 @@
                 editor.taskDetail.value.taskStatus === 'jxz' ||
                 editor.taskDetail.value.taskStatus === 'ywc'
               "
-              class="btn-main"
+              class="btn-main task-exec-toolbar-btn"
               type="success"
-              size="small"
               :loading="editor.taskActionLoading.value"
               @click="editor.handleCompleteTask"
             >
@@ -263,13 +261,13 @@
               <h3>📥 原材料(样本/细胞产品)<span class="virtual-step-required">必填</span></h3>
               <div v-if="!readonly" class="execution-detail-actions">
                 <el-button
-                  class="btn-main"
+                  class="btn-main task-exec-toolbar-btn"
                   type="primary"
-                  size="small"
                   :loading="editor.inputSaving.value"
                   @click="editor.saveTaskInputs"
-                  ><el-icon><Check /></el-icon>保存</el-button
                 >
+                  <el-icon><Check /></el-icon>保存
+                </el-button>
               </div>
             </div>
             <div class="execution-detail-body">
@@ -293,13 +291,13 @@
               <h3>📤 产出(细胞产品)</h3>
               <div v-if="!readonly" class="execution-detail-actions">
                 <el-button
-                  class="btn-main"
+                  class="btn-main task-exec-toolbar-btn"
                   type="primary"
-                  size="small"
                   :loading="editor.outputSaving.value"
                   @click="editor.saveTaskOutputs"
-                  ><el-icon><Check /></el-icon>保存</el-button
                 >
+                  <el-icon><Check /></el-icon>保存
+                </el-button>
               </div>
             </div>
             <div class="execution-detail-body">
@@ -712,3 +710,26 @@
     margin-right: 8px;
   }
 </style>
+
+<!-- 弹窗 teleport 到 body,工具栏按钮样式需挂 execution-dialog 全局类 -->
+<style lang="scss">
+  .execution-dialog {
+    .task-exec-toolbar-btn.el-button {
+      height: 36px;
+      min-height: 36px;
+      padding: 0 14px;
+      border-radius: 8px;
+      font-size: 14px;
+      font-weight: 600;
+
+      > span {
+        font-size: 14px;
+        gap: 6px;
+      }
+
+      .el-icon {
+        font-size: 14px;
+      }
+    }
+  }
+</style>

+ 2 - 2
web-admin/src/views/production/production-work-order/TaskInputEnhancedTable.vue

@@ -4,11 +4,11 @@
     <div class="panel-top">
       <h2 class="title-bar-heading">原材料(样本/细胞产品)</h2>
       <div v-if="!disabled" class="panel-top-actions">
-        <el-button type="primary" size="small" @click="handleAddSample">
+        <el-button class="btn-main task-exec-toolbar-btn" type="primary" @click="handleAddSample">
           <el-icon><Plus /></el-icon>
           添加样本
         </el-button>
-        <el-button size="small" @click="handleAddCellProduct">
+        <el-button class="btn-ghost task-exec-toolbar-btn" @click="handleAddCellProduct">
           <el-icon><Plus /></el-icon>
           添加细胞产品
         </el-button>

+ 6 - 1
web-admin/src/views/production/production-work-order/TaskOutputList.vue

@@ -3,7 +3,12 @@
     <!-- 工具栏 -->
     <div class="panel-top">
       <h2 class="title-bar-heading">产出(细胞产品)</h2>
-      <el-button v-if="!disabled" type="primary" size="small" @click="handleAdd">
+      <el-button
+        v-if="!disabled"
+        class="btn-main task-exec-toolbar-btn"
+        type="primary"
+        @click="handleAdd"
+      >
         <el-icon><Plus /></el-icon>
         新增产出
       </el-button>

+ 230 - 135
web-admin/src/views/production/production-work-order/WorkOrderTaskSubList.vue

@@ -1,72 +1,46 @@
 <!-- 生产工单展开行 · 生产任务子列表 -->
 <template>
   <div class="work-order-task-sublist">
-    <div class="list-toolbar work-order-task-sublist__toolbar">
-      <div class="list-toolbar-row">
-        <el-button
-          class="btn-main"
-          type="primary"
-          size="small"
-          :disabled="!canDispatch"
-          @click="handleDispatch"
+    <div class="work-order-task-sublist__panel" v-loading="loading">
+      <div v-if="tableData.length" class="work-order-task-cards-wrap">
+        <div class="work-order-task-sublist__summary">
+          <span class="work-order-task-sublist__count">共 {{ tableData.length }} 项任务</span>
+          <el-button
+            class="work-order-task-sublist__refresh-icon"
+            link
+            type="primary"
+            :loading="loading"
+            @click="loadData"
+          >
+            <el-icon><RefreshLeft /></el-icon>
+          </el-button>
+          <el-button
+            class="btn-main work-order-task-sublist__dispatch"
+            type="primary"
+            size="small"
+            :disabled="!canDispatch"
+            @click="handleDispatch"
+          >
+            <el-icon><Position /></el-icon>派发任务
+          </el-button>
+        </div>
+        <div
+          v-for="(row, idx) in tableData"
+          :key="row.id || idx"
+          class="work-order-task-card"
+          @dblclick="handleViewDetail(row)"
         >
-          <el-icon><Position /></el-icon>派发任务
-        </el-button>
-        <el-button class="btn-ghost" size="small" :loading="loading" @click="loadData">
-          <el-icon><RefreshLeft /></el-icon>刷新
-        </el-button>
-      </div>
-    </div>
-
-    <div class="work-order-task-sublist__body">
-      <el-table
-        class="list-table work-order-task-sublist__table"
-        :data="tableData"
-        v-loading="loading"
-        stripe
-        size="small"
-        @row-dblclick="handleViewDetail"
-      >
-        <el-table-column
-          type="index"
-          label="序号"
-          min-width="68"
-          align="center"
-          :index="(i) => i + 1"
-        />
-        <el-table-column prop="taskNo" label="任务号" min-width="150" align="center" />
-        <el-table-column prop="taskType" label="任务类型" min-width="120" align="center">
-          <template #default="{ row }">
+          <div class="work-order-task-card__head">
+            <span class="work-order-task-card__name">{{ row.taskName || row.taskNo || '—' }}</span>
+            <el-tag :type="taskStatusTag(row.taskStatus).type" size="small">
+              {{ taskStatusTag(row.taskStatus).label }}
+            </el-tag>
+          </div>
+          <div class="work-order-task-card__meta">
+            <span class="work-order-task-card__code">{{ row.taskNo || '—' }}</span>
             <el-tag :type="productionTaskTypeTag(row.taskType, row.orderSource).type" size="small">
               {{ productionTaskTypeTag(row.taskType, row.orderSource).label }}
             </el-tag>
-          </template>
-        </el-table-column>
-        <el-table-column
-          prop="taskName"
-          label="任务名称"
-          min-width="160"
-          align="center"
-          show-overflow-tooltip
-        />
-        <el-table-column
-          prop="routeName"
-          label="工艺路线"
-          min-width="140"
-          align="center"
-          show-overflow-tooltip
-        >
-          <template #default="{ row }">{{ row.routeName || '—' }}</template>
-        </el-table-column>
-        <el-table-column prop="taskStatus" label="任务状态" min-width="110" align="center">
-          <template #default="{ row }">
-            <el-tag :type="taskStatusTag(row.taskStatus).type" size="small">{{
-              taskStatusTag(row.taskStatus).label
-            }}</el-tag>
-          </template>
-        </el-table-column>
-        <el-table-column prop="conclusion" label="生产结论" min-width="100" align="center">
-          <template #default="{ row }">
             <el-tag
               v-if="productionConclusionTagType(row.conclusion)"
               :type="productionConclusionTagType(row.conclusion)"
@@ -74,63 +48,81 @@
             >
               {{ row.conclusionLabel || productionConclusionLabel(row.conclusion) }}
             </el-tag>
-            <span v-else>—</span>
-          </template>
-        </el-table-column>
-        <el-table-column prop="startedTime" label="开始时间" min-width="170" align="center">
-          <template #default="{ row }">{{ row.startedTime || '—' }}</template>
-        </el-table-column>
-        <el-table-column prop="completedTime" label="完成时间" min-width="170" align="center">
-          <template #default="{ row }">{{ row.completedTime || '—' }}</template>
-        </el-table-column>
-        <el-table-column label="操作" min-width="260" fixed="right" align="center">
-          <template #default="{ row }">
-            <div class="work-order-task-sublist__ops">
-              <el-button
-                v-if="canEnterExecution(row)"
-                class="btn-op-item primary"
-                type="primary"
-                @click="handleEnterExecution(row)"
-              >
-                <el-icon><VideoPlay /></el-icon>进入执行
-              </el-button>
-              <el-button v-else class="btn-op-item primary" @click="handleViewDetail(row)">
-                <el-icon><View /></el-icon>查看
+          </div>
+          <div class="work-order-task-sublist__ops">
+            <el-button
+              v-if="canEnterExecution(row)"
+              class="btn-op-item primary"
+              type="primary"
+              size="small"
+              @click.stop="handleEnterExecution(row)"
+            >
+              <el-icon><VideoPlay /></el-icon>进入执行
+            </el-button>
+            <el-button
+              v-else
+              class="btn-op-item primary"
+              type="primary"
+              size="small"
+              @click.stop="handleViewDetail(row)"
+            >
+              <el-icon><View /></el-icon>查看
+            </el-button>
+            <el-dropdown
+              v-if="hasMoreTaskActions(row)"
+              trigger="hover"
+              @command="(cmd) => handleDropdownCommand(cmd, row)"
+            >
+              <el-button class="btn-op-item" size="small" @click.stop>
+                <el-icon><More /></el-icon>
               </el-button>
-              <el-dropdown
-                v-if="hasMoreTaskActions(row)"
-                trigger="hover"
-                @command="(cmd) => handleDropdownCommand(cmd, row)"
-              >
-                <el-button class="btn-op-item">
-                  <el-icon><More /></el-icon>
-                </el-button>
-                <template #dropdown>
-                  <el-dropdown-menu>
-                    <el-dropdown-item v-if="canEnterExecution(row)" command="view">
-                      <el-icon><View /></el-icon>查看
-                    </el-dropdown-item>
-                    <el-dropdown-item v-if="canEditTask(row)" command="editInfo">
-                      <el-icon><EditPen /></el-icon>编辑信息
-                    </el-dropdown-item>
-                    <el-dropdown-item v-if="canEditTask(row)" command="editRoute">
-                      <el-icon><EditPen /></el-icon>编辑工艺
-                    </el-dropdown-item>
-                    <el-dropdown-item v-if="canDeleteTask(row)" command="delete" divided>
-                      <el-icon><Delete /></el-icon>删除
-                    </el-dropdown-item>
-                  </el-dropdown-menu>
-                </template>
-              </el-dropdown>
-            </div>
-          </template>
-        </el-table-column>
-      </el-table>
-      <el-empty
-        v-if="!loading && !tableData.length"
-        description="暂无任务,可点击「派发任务」创建"
-        :image-size="72"
-      />
+              <template #dropdown>
+                <el-dropdown-menu>
+                  <el-dropdown-item v-if="canEnterExecution(row)" command="view">
+                    <el-icon><View /></el-icon>查看
+                  </el-dropdown-item>
+                  <el-dropdown-item v-if="canEditTask(row)" command="editInfo">
+                    <el-icon><EditPen /></el-icon>编辑信息
+                  </el-dropdown-item>
+                  <el-dropdown-item v-if="canEditTask(row)" command="editRoute">
+                    <el-icon><EditPen /></el-icon>编辑工艺
+                  </el-dropdown-item>
+                  <el-dropdown-item v-if="canDeleteTask(row)" command="delete" divided>
+                    <el-icon><Delete /></el-icon>删除
+                  </el-dropdown-item>
+                </el-dropdown-menu>
+              </template>
+            </el-dropdown>
+          </div>
+        </div>
+      </div>
+      <div v-else-if="!loading" class="work-order-task-cards-wrap work-order-task-cards-wrap--empty">
+        <div class="work-order-task-sublist__summary">
+          <el-button
+            class="work-order-task-sublist__refresh-icon"
+            link
+            type="primary"
+            :loading="loading"
+            @click="loadData"
+          >
+            <el-icon><RefreshLeft /></el-icon>
+          </el-button>
+          <el-button
+            class="btn-main work-order-task-sublist__dispatch"
+            type="primary"
+            size="small"
+            :disabled="!canDispatch"
+            @click="handleDispatch"
+          >
+            <el-icon><Position /></el-icon>派发任务
+          </el-button>
+        </div>
+        <el-empty
+          class="work-order-task-sublist__empty"
+          description="暂无任务,可点击「派发任务」创建"
+          :image-size="56"
+        />
+      </div>
     </div>
 
     <TaskExecutionDialog
@@ -325,36 +317,139 @@
 
 <style scoped lang="scss">
   .work-order-task-sublist {
-    padding: 12px 16px 16px 40px;
+    padding: 8px 12px 10px 40px;
     background: #f8fafc;
     text-align: left;
   }
 
-  .work-order-task-sublist__toolbar {
-    padding: 0 0 8px;
-    border-bottom: none;
+  .work-order-task-sublist__panel {
+    min-height: 32px;
   }
 
-  .work-order-task-sublist__body {
-    display: block;
-    width: 100%;
-    max-width: 100%;
-    overflow: hidden;
+  .work-order-task-cards-wrap {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: flex-start;
+    gap: 8px;
+  }
+
+  .work-order-task-sublist__summary {
+    flex: 0 0 auto;
+    display: inline-flex;
+    align-items: center;
+    flex-wrap: wrap;
+    gap: 4px 8px;
+    padding-top: 8px;
+    min-height: 34px;
+  }
+
+  .work-order-task-sublist__count {
+    font-size: 12px;
+    color: #94a3b8;
+    line-height: 1.35;
+    white-space: nowrap;
+  }
+
+  .work-order-task-sublist__refresh-icon {
+    flex-shrink: 0;
+    padding: 0 2px;
+    height: 20px;
+    font-size: 14px;
+
+    .el-icon {
+      font-size: 14px;
+    }
+  }
+
+  .work-order-task-sublist__dispatch {
+    flex-shrink: 0;
   }
 
-  .work-order-task-sublist__table {
-    width: 100%;
+  .work-order-task-cards-wrap--empty {
+    align-items: center;
 
-    :deep(.el-table__inner-wrapper),
-    :deep(.el-table__header-wrapper),
-    :deep(.el-table__body-wrapper) {
-      width: 100%;
+    .work-order-task-sublist__summary {
+      padding-top: 0;
+      min-height: auto;
     }
   }
 
+  .work-order-task-sublist__empty {
+    flex: 1;
+    min-width: 200px;
+    padding: 4px 0;
+
+    :deep(.el-empty__description) {
+      margin-top: 4px;
+    }
+  }
+
+  .work-order-task-card {
+    display: flex;
+    flex-direction: column;
+    gap: 6px;
+    flex: 0 0 248px;
+    width: 248px;
+    max-width: 248px;
+    padding: 8px 10px;
+    border: 1px solid #e2e8f0;
+    border-radius: 8px;
+    background: #fff;
+    cursor: default;
+    transition: border-color 0.15s ease, box-shadow 0.15s ease;
+
+    &:hover {
+      border-color: #a7f3d0;
+      box-shadow: 0 2px 6px rgba(16, 185, 129, 0.1);
+    }
+  }
+
+  .work-order-task-card__head {
+    display: flex;
+    align-items: flex-start;
+    justify-content: space-between;
+    gap: 6px;
+    min-width: 0;
+  }
+
+  .work-order-task-card__name {
+    flex: 1;
+    min-width: 0;
+    font-size: 13px;
+    font-weight: 600;
+    color: #1e293b;
+    line-height: 1.35;
+    display: -webkit-box;
+    -webkit-line-clamp: 2;
+    -webkit-box-orient: vertical;
+    overflow: hidden;
+    word-break: break-word;
+  }
+
+  .work-order-task-card__meta {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    gap: 4px 6px;
+    min-width: 0;
+  }
+
+  .work-order-task-card__code {
+    font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+    font-size: 11px;
+    color: #475569;
+    line-height: 1.35;
+    max-width: 100%;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
   .work-order-task-sublist__ops {
     display: flex;
-    justify-content: center;
-    gap: 8px;
+    flex-wrap: wrap;
+    align-items: center;
+    gap: 6px;
+    padding-top: 2px;
   }
 </style>

+ 31 - 71
web-admin/src/views/production/production-work-order/index.vue

@@ -141,9 +141,14 @@
             align="center"
           >
             <template #default="{ row }">
-              <el-tag :type="row.bizType === 'pilot_service' ? 'primary' : 'success'" size="small">
-                {{ row.bizTypeLabel || '—' }}
+              <el-tag
+                v-if="row.bizType"
+                :type="bizTypeTagType(row.bizType) as TagType"
+                size="small"
+              >
+                {{ row.bizTypeLabel || bizTypeLabel(row.bizType) }}
               </el-tag>
+              <span v-else>—</span>
             </template>
           </el-table-column>
           <el-table-column
@@ -546,7 +551,9 @@
     donationStatusLabel,
     donationStatusTagType,
     productStatusLabel,
-    productStatusTagType
+    productStatusTagType,
+    bizTypeLabel,
+    bizTypeTagType
   } from '@/utils/status-tag-registry'
   import { buildOutboundPilotContextSections } from '@/utils/outbound-qc-pilot-view-sections'
   import type { OutboundQcDetail } from '@/api/qc-work-order'
@@ -873,15 +880,17 @@
 
   const viewVisible = ref(false)
   const detail = ref<ProductionWorkOrderDetail | null>(null)
-  const isOutboundPilotService = computed(
+  const isOutboundContextView = computed(
     () =>
-      detail.value?.workOrderType === 'outbound' && detail.value?.bizType === 'pilot_service'
+      detail.value?.workOrderType === 'outbound' &&
+      (detail.value?.bizType === 'pilot_service' ||
+        detail.value?.bizType === 'clinical_application')
   )
-  const viewDialogWidth = computed(() => (isOutboundPilotService.value ? '1200px' : '720px'))
+  const viewDialogWidth = computed(() => (isOutboundContextView.value ? '1200px' : '720px'))
   const viewDialogGridColumns = computed<2 | 3 | 4>(() =>
-    isOutboundPilotService.value ? 4 : 2
+    isOutboundContextView.value ? 4 : 2
   )
-  const viewDialogCompact = computed(() => isOutboundPilotService.value)
+  const viewDialogCompact = computed(() => isOutboundContextView.value)
   const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
 
   const viewReceptionAttachmentFiles = computed(() => {
@@ -959,14 +968,8 @@
     if (isOutbound) {
       baseFields.push({
         label: '业务类型',
-        value:
-          d.bizTypeLabel ||
-          (d.bizType === 'pilot_service'
-            ? '中试服务'
-            : d.bizType === 'clinical_application'
-              ? '临床应用'
-              : '—'),
-        tagType: 'primary' as const
+        value: d.bizTypeLabel || bizTypeLabel(d.bizType || '') || '—',
+        tagType: bizTypeTagType(d.bizType || '') as TagType
       })
     }
 
@@ -1010,62 +1013,19 @@
 
     const sections: ViewSection[] = [{ title: '基本信息', fields: baseFields }]
 
-    // outbound: 中试服务 — 与出库质检工单查看弹窗一致;临床应用 — 保留简要申请/输注信息
-    if (isOutbound) {
-      if (d.bizType === 'pilot_service') {
-        const pilotDetail: OutboundQcDetail = {
-          workOrder: {} as OutboundQcDetail['workOrder'],
-          application: d.application,
-          patient: d.patient,
-          infusionPlan: d.infusionPlan,
-          infusionSchedule: d.infusionSchedule,
-          products: d.products
-        }
-        sections.push(...buildOutboundPilotContextSections(pilotDetail))
-      } else {
-        const pa = d.pilotApplication || {}
-        const sched = d.infusionSchedule || {}
-        const appStatusLabel = (s: string) => {
-          const m: Record<string, string> = {
-            dks: '待开始',
-            jxz: '进行中',
-            ywc: '已完成',
-            yzf: '已作废'
-          }
-          return m[s] || s || '—'
-        }
-        const appStatusTagType = (s: string) => {
-          const m: Record<string, string> = {
-            dks: 'info',
-            jxz: 'warning',
-            ywc: 'success',
-            yzf: 'danger'
-          }
-          return m[s] || 'info'
-        }
-        sections.push({
-          title: '中试申请信息',
-          fields: [
-            { label: '申请单号', value: pa.applicationNo || '—' },
-            { label: '申请名称', value: pa.applicationName || '—' },
-            {
-              label: '申请状态',
-              value: appStatusLabel(pa.applicationStatus || ''),
-              tagType: appStatusTagType(pa.applicationStatus || '') as any
-            }
-          ]
-        })
-        sections.push({
-          title: '输注计划信息',
-          fields: [
-            { label: '细胞类型', value: sched.cellType || '—' },
-            { label: '规格', value: sched.specText || '—' },
-            { label: '输注时间', value: sched.infusionTime || '—' },
-            { label: '输注地点', value: sched.infusionLocation || '—' },
-            { label: '输注方式', value: sched.infusionMethod || '—' }
-          ]
-        })
+    // outbound:中试服务 / 临床应用 — 与出库质检一致,码值展示中文标签
+    if (isOutbound && (d.bizType === 'pilot_service' || d.bizType === 'clinical_application')) {
+      const outboundDetail: OutboundQcDetail = {
+        workOrder: {} as OutboundQcDetail['workOrder'],
+        application: d.application,
+        patient: d.patient,
+        infusionPlan: d.infusionPlan,
+        infusionSchedule: d.infusionSchedule,
+        products: d.products
       }
+      const variant =
+        d.bizType === 'clinical_application' ? 'clinical_application' : 'pilot_service'
+      sections.push(...buildOutboundPilotContextSections(outboundDetail, variant))
       return sections
     }
 

+ 28 - 2
web-admin/src/views/quality-inspection/qc-work-order/OutboundQcViewDialog.vue

@@ -20,7 +20,13 @@
       />
     </template>
     <template #product-table>
-      <el-table :data="detail?.products || []" stripe size="small" class="outbound-product-table">
+      <div v-if="detail?.products?.length" class="view-subtable-wrap">
+      <el-table
+        :data="detail?.products || []"
+        stripe
+        size="small"
+        class="list-table view-subtable outbound-product-table"
+      >
         <el-table-column type="index" label="序号" min-width="60" align="center" />
         <el-table-column prop="projectCode" label="项目编码" min-width="120" align="center" show-overflow-tooltip />
         <el-table-column prop="projectName" label="项目名称" min-width="120" align="center" show-overflow-tooltip />
@@ -35,6 +41,8 @@
           </template>
         </el-table-column>
       </el-table>
+      </div>
+      <p v-else class="view-empty-hint">暂无产品明细</p>
     </template>
     <template v-for="task in tasksWithAttachments" #[taskAttachmentSlotName(task.id)]>
       <div :key="task.id" class="outbound-qc-task-attachments">
@@ -469,6 +477,24 @@
   }
 
   .outbound-product-table {
-    margin-top: 4px;
+    margin-top: 0;
+
+    :deep(.el-table__header th.el-table__cell),
+    :deep(.el-table__body td.el-table__cell) {
+      font-size: 12px;
+      padding: 6px 0;
+    }
+
+    :deep(.el-table__header th.el-table__cell .cell),
+    :deep(.el-table__body td.el-table__cell .cell) {
+      font-size: 12px;
+      line-height: 1.35;
+    }
+
+    :deep(.el-tag) {
+      --el-tag-font-size: 11px;
+      height: 20px;
+      padding: 0 6px;
+    }
   }
 </style>

+ 236 - 126
web-admin/src/views/quality-inspection/qc-work-order/QcWorkOrderTaskSubList.vue

@@ -2,131 +2,116 @@
 <!-- eslint-disable vue/no-v-model-argument -->
 <template>
   <div class="qc-work-order-task-sublist">
-    <div class="list-toolbar qc-work-order-task-sublist__toolbar">
-      <div class="list-toolbar-row">
-        <el-button class="btn-ghost" size="small" :loading="loading" @click="loadData">
-          <el-icon><RefreshLeft /></el-icon>刷新
-        </el-button>
-      </div>
-    </div>
-
-    <div class="qc-work-order-task-sublist__body sqxp-subtable-wrap">
-      <el-table
-        class="list-table qc-work-order-task-sublist__table sqxp-subtable"
-        :data="tableData"
-        v-loading="loading"
-        stripe
-        size="small"
-        @row-dblclick="handleView"
-      >
-          <el-table-column
-            type="index"
-            label="序号"
-            min-width="68"
-            align="center"
-            :index="(i) => i + 1"
-          />
-          <el-table-column prop="taskNo" label="任务号" min-width="150" align="center" />
-          <el-table-column prop="taskType" label="检测类型" min-width="110" align="center">
-            <template #default="{ row }">
-              <el-tag :type="row.taskType === 'sysz' ? 'success' : 'info'" size="small">{{
-                taskTypeLabel(row.taskType)
-              }}</el-tag>
-            </template>
-          </el-table-column>
-          <el-table-column prop="itemName" label="检测项目" min-width="160" align="center" />
-          <el-table-column prop="companyName" label="第三方公司" min-width="120" align="center">
-            <template #default="{ row }">{{ row.companyName || '—' }}</template>
-          </el-table-column>
-          <el-table-column prop="conclusion" label="质检结论" min-width="100" align="center">
-            <template #default="{ row }">
-              <el-tag v-if="row.conclusion === 'tg'" type="success" size="small">
-                {{ row.conclusionLabel || qcTaskConclusionLabel(row.conclusion) }}
-              </el-tag>
-              <el-tag v-else-if="row.conclusion === 'btg'" type="danger" size="small">
-                {{ row.conclusionLabel || qcTaskConclusionLabel(row.conclusion) }}
-              </el-tag>
-              <el-tag v-else type="info" size="small">—</el-tag>
-            </template>
-          </el-table-column>
-          <el-table-column prop="taskStatus" label="状态" min-width="100" align="center">
-            <template #default="{ row }">
-              <el-tag
-                :type="
-                  row.taskStatus === 'ywc'
-                    ? 'success'
-                    : row.taskStatus === 'jxz'
-                      ? 'warning'
-                      : 'info'
-                "
+    <div class="qc-work-order-task-sublist__panel" v-loading="loading">
+      <div v-if="tableData.length" class="qc-task-cards-wrap">
+        <div class="qc-work-order-task-sublist__summary">
+          <span class="qc-work-order-task-sublist__count">共 {{ tableData.length }} 项任务</span>
+          <el-button
+            class="qc-work-order-task-sublist__refresh-icon"
+            link
+            type="primary"
+            :loading="loading"
+            @click="loadData"
+          >
+            <el-icon><RefreshLeft /></el-icon>
+          </el-button>
+        </div>
+        <div
+          v-for="(row, idx) in tableData"
+          :key="row.id || idx"
+          class="qc-task-card"
+          @dblclick="handleView(row)"
+        >
+          <div class="qc-task-card__head">
+            <span class="qc-task-card__name">{{ row.itemName || row.taskNo || '—' }}</span>
+            <el-tag :type="taskStatusTagType(row.taskStatus)" size="small">
+              {{ taskStatusLabel(row.taskStatus) }}
+            </el-tag>
+          </div>
+          <div class="qc-task-card__meta">
+            <span class="qc-task-card__code">{{ row.taskNo || '—' }}</span>
+            <el-tag :type="taskTypeTagType(row.taskType)" size="small">
+              {{ taskTypeLabel(row.taskType) }}
+            </el-tag>
+            <el-tag
+              v-if="row.conclusion === 'tg' || row.conclusion === 'btg'"
+              :type="conclusionTagType(row.conclusion)"
+              size="small"
+            >
+              {{ row.conclusionLabel || qcTaskConclusionLabel(row.conclusion) }}
+            </el-tag>
+          </div>
+          <div class="qc-work-order-task-sublist__ops">
+            <template v-if="hasPrimaryTaskActions(row)">
+              <el-button
+                v-if="canStartTask(row)"
+                class="btn-op-item primary"
+                type="primary"
+                size="small"
+                @click.stop="handleStart(row)"
+              >
+                <el-icon><VideoPlay /></el-icon>开始
+              </el-button>
+              <el-button
+                v-if="canFillResult(row)"
+                class="btn-op-item primary"
+                type="primary"
                 size="small"
+                @click.stop="openResult(row)"
               >
-                {{ taskStatusLabel(row.taskStatus) }}
-              </el-tag>
+                <el-icon><EditPen /></el-icon>填写结果
+              </el-button>
             </template>
-          </el-table-column>
-          <el-table-column
-            prop="remark"
-            label="备注"
-            min-width="140"
-            align="center"
-            show-overflow-tooltip
+            <el-button
+              v-else
+              class="btn-op-item primary"
+              type="primary"
+              size="small"
+              @click.stop="handleView(row)"
+            >
+              <el-icon><View /></el-icon>查看
+            </el-button>
+            <el-dropdown
+              v-if="hasMoreTaskActions(row)"
+              trigger="hover"
+              @command="(cmd) => handleDropdownCommand(cmd, row)"
+            >
+              <el-button class="btn-op-item" size="small" @click.stop>
+                <el-icon><More /></el-icon>
+              </el-button>
+              <template #dropdown>
+                <el-dropdown-menu>
+                  <el-dropdown-item v-if="hasPrimaryTaskActions(row)" command="view">
+                    <el-icon><View /></el-icon>查看
+                  </el-dropdown-item>
+                  <el-dropdown-item v-if="canDeleteTask(row)" command="delete" divided>
+                    <el-icon><Delete /></el-icon>删除
+                  </el-dropdown-item>
+                </el-dropdown-menu>
+              </template>
+            </el-dropdown>
+          </div>
+        </div>
+      </div>
+      <div v-else-if="!loading" class="qc-task-cards-wrap qc-task-cards-wrap--empty">
+        <div class="qc-work-order-task-sublist__summary">
+          <el-button
+            class="qc-work-order-task-sublist__refresh-icon"
+            link
+            type="primary"
+            :loading="loading"
+            @click="loadData"
           >
-            <template #default="{ row }">{{ row.remark || '—' }}</template>
-          </el-table-column>
-          <el-table-column label="操作" min-width="260" fixed="right" align="center">
-            <template #default="{ row }">
-              <div class="qc-work-order-task-sublist__ops">
-                <template v-if="hasPrimaryTaskActions(row)">
-                  <el-button
-                    v-if="canStartTask(row)"
-                    class="btn-op-item primary"
-                    type="primary"
-                    @click="handleStart(row)"
-                  >
-                    <el-icon><VideoPlay /></el-icon>开始
-                  </el-button>
-                  <el-button
-                    v-if="canFillResult(row)"
-                    class="btn-op-item primary"
-                    type="primary"
-                    @click="openResult(row)"
-                  >
-                    <el-icon><EditPen /></el-icon>填写结果
-                  </el-button>
-                </template>
-                <el-button v-else class="btn-op-item primary" @click="handleView(row)">
-                  <el-icon><View /></el-icon>查看
-                </el-button>
-                <el-dropdown
-                  v-if="hasMoreTaskActions(row)"
-                  trigger="hover"
-                  @command="(cmd) => handleDropdownCommand(cmd, row)"
-                >
-                  <el-button class="btn-op-item">
-                    <el-icon><More /></el-icon>
-                  </el-button>
-                  <template #dropdown>
-                    <el-dropdown-menu>
-                      <el-dropdown-item v-if="hasPrimaryTaskActions(row)" command="view">
-                        <el-icon><View /></el-icon>查看
-                      </el-dropdown-item>
-                      <el-dropdown-item v-if="canDeleteTask(row)" command="delete" divided>
-                        <el-icon><Delete /></el-icon>删除
-                      </el-dropdown-item>
-                    </el-dropdown-menu>
-                  </template>
-                </el-dropdown>
-              </div>
-            </template>
-          </el-table-column>
-        </el-table>
+            <el-icon><RefreshLeft /></el-icon>
+          </el-button>
+        </div>
         <el-empty
-          v-if="!loading && !tableData.length"
+          class="qc-work-order-task-sublist__empty"
           description="暂无任务,请先在工单行「配置检测」"
-          :image-size="72"
+          :image-size="56"
         />
       </div>
+    </div>
 
       <el-dialog
         v-model="resultVisible"
@@ -607,6 +592,22 @@
     }
   }
 
+  function taskStatusTagType(status: string) {
+    if (status === 'ywc') return 'success' as const
+    if (status === 'jxz') return 'warning' as const
+    return 'info' as const
+  }
+
+  function taskTypeTagType(type: string) {
+    return type === 'sysz' ? ('success' as const) : ('info' as const)
+  }
+
+  function conclusionTagType(conclusion: string) {
+    if (conclusion === 'tg') return 'success' as const
+    if (conclusion === 'btg') return 'danger' as const
+    return 'info' as const
+  }
+
   const resultSubmitLabel = computed(() =>
     resultTaskStatus.value === 'ywc' ? '保存' : '提交并完成'
   )
@@ -1212,26 +1213,135 @@
 
 <style scoped lang="scss">
   .qc-work-order-task-sublist {
-    padding: 12px 16px 16px 40px;
+    padding: 8px 12px 10px 40px;
     background: #f8fafc;
     text-align: left;
   }
 
-  .qc-work-order-task-sublist__toolbar {
-    padding: 0 0 8px;
-    border-bottom: none;
+  .qc-work-order-task-sublist__panel {
+    min-height: 32px;
+  }
+
+  .qc-task-cards-wrap {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: flex-start;
+    gap: 8px;
+  }
+
+  .qc-work-order-task-sublist__summary {
+    flex: 0 0 auto;
+    display: inline-flex;
+    align-items: center;
+    gap: 2px;
+    padding-top: 8px;
+    min-height: 34px;
+  }
+
+  .qc-work-order-task-sublist__count {
+    font-size: 12px;
+    color: #94a3b8;
+    line-height: 1.35;
+    white-space: nowrap;
+  }
+
+  .qc-work-order-task-sublist__refresh-icon {
+    flex-shrink: 0;
+    padding: 0 2px;
+    height: 20px;
+    font-size: 14px;
+
+    .el-icon {
+      font-size: 14px;
+    }
   }
 
-  .qc-work-order-task-sublist__body {
-    display: block;
-    width: 100%;
+  .qc-task-cards-wrap--empty {
+    align-items: center;
+
+    .qc-work-order-task-sublist__summary {
+      padding-top: 0;
+      min-height: auto;
+    }
+  }
+
+  .qc-work-order-task-sublist__empty {
+    flex: 1;
+    min-width: 200px;
+    padding: 4px 0;
+
+    :deep(.el-empty__description) {
+      margin-top: 4px;
+    }
+  }
+
+  .qc-task-card {
+    display: flex;
+    flex-direction: column;
+    gap: 6px;
+    flex: 0 0 248px;
+    width: 248px;
+    max-width: 248px;
+    padding: 8px 10px;
+    border: 1px solid #e2e8f0;
+    border-radius: 8px;
+    background: #fff;
+    cursor: default;
+    transition: border-color 0.15s ease, box-shadow 0.15s ease;
+
+    &:hover {
+      border-color: #a7f3d0;
+      box-shadow: 0 2px 6px rgba(16, 185, 129, 0.1);
+    }
+  }
+
+  .qc-task-card__head {
+    display: flex;
+    align-items: flex-start;
+    justify-content: space-between;
+    gap: 6px;
+    min-width: 0;
+  }
+
+  .qc-task-card__name {
+    flex: 1;
+    min-width: 0;
+    font-size: 13px;
+    font-weight: 600;
+    color: #1e293b;
+    line-height: 1.35;
+    display: -webkit-box;
+    -webkit-line-clamp: 2;
+    -webkit-box-orient: vertical;
+    overflow: hidden;
+    word-break: break-word;
+  }
+
+  .qc-task-card__meta {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    gap: 4px 6px;
+    min-width: 0;
+  }
+
+  .qc-task-card__code {
+    font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+    font-size: 11px;
+    color: #475569;
+    line-height: 1.35;
     max-width: 100%;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
   }
 
   .qc-work-order-task-sublist__ops {
     display: flex;
-    justify-content: center;
-    gap: 8px;
+    flex-wrap: wrap;
+    align-items: center;
+    gap: 6px;
+    padding-top: 2px;
   }
 
   .form-section {

Некоторые файлы не были показаны из-за большого количества измененных файлов