This commit is contained in:
zlwu committed 2026-06-12 12:32:49 +08:00
1 parent 9a73e87ce8
commit 4ea71a1bae
70 files changed
+24056 -18383

No files matched your search

@@ -0,0 +1,219 @@
<!-- 质检管理 — 我的质检任务 -->
<template>
<div ref="pageRef" class="page-container my-qc-task-page" :style="pageStyle">
<section class="panel my-qc-task-panel">
<div class="panel-top">
<h2 class="title-bar-heading">我的质检任务</h2>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input
v-model="searchForm.keyword"
placeholder="任务号 / 项目 / 工单号 / 样本编码"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="任务状态">
<el-select v-model="searchForm.taskStatus" clearable placeholder="全部">
<el-option label="待执行" value="dzx" />
<el-option label="进行中" value="jxz" />
<el-option label="已完成" value="ywc" />
</el-select>
</el-form-item>
<el-form-item label="任务类型">
<el-select v-model="searchForm.taskType" clearable placeholder="全部">
<el-option label="实验室自检" value="sysz" />
<el-option label="第三方检测" value="dsfjc" />
</el-select>
</el-form-item>
<el-form-item label="工单类型">
<el-select v-model="searchForm.qcBizType" clearable placeholder="全部">
<el-option label="入库质检" value="inbound" />
<el-option label="出库质检" value="outbound" />
<el-option label="临时质检" value="temporary" />
</el-select>
</el-form-item>
</div>
<div class="filter-actions-row">
<span class="filter-hint">仅展示指派给当前登录人的质检任务。</span>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset"
><el-icon><RefreshLeft /></el-icon>重置</el-button
>
<el-button class="btn-main" type="primary" @click="handleSearch"
><el-icon><Search /></el-icon>搜索</el-button
>
</div>
</div>
</el-form>
</div>
<div class="list-body list-wrap my-qc-task-list">
<div class="my-qc-task-cards">
<QcWorkOrderTaskSubList
list-mode="mine"
:mine-filters="appliedFilters"
:page="pagination.page"
:page-size="pagination.pageSize"
:refresh-token="refreshToken"
@update:total="pagination.total = $event"
/>
</div>
<div class="pagination list-pager-bar">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[30, 60, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted, onUnmounted, nextTick } from 'vue'
import { Search, RefreshLeft } from '@element-plus/icons-vue'
import QcWorkOrderTaskSubList from '../qc-work-order/QcWorkOrderTaskSubList.vue'
defineOptions({ name: 'QualityInspectionMyQcTask' })
const PAGE_BOTTOM_GAP = 20
const pageRef = ref<HTMLElement>()
const pageStyle = ref<Record<string, string>>({})
let pageResizeObserver: ResizeObserver | undefined
function updatePageHeight() {
const el = pageRef.value
if (!el) return
const top = el.getBoundingClientRect().top
const layoutContent = el.closest('.layout-content') as HTMLElement | null
const paddingBottom = layoutContent
? Number.parseInt(getComputedStyle(layoutContent).paddingBottom, 10) || 0
: 0
const height = Math.max(200, window.innerHeight - top - paddingBottom - PAGE_BOTTOM_GAP)
pageStyle.value = {
height: `${height}px`,
overflow: 'hidden'
}
}
function bindPageHeightListeners() {
updatePageHeight()
window.addEventListener('resize', updatePageHeight)
pageResizeObserver?.disconnect()
pageResizeObserver = new ResizeObserver(() => updatePageHeight())
const panel = pageRef.value?.querySelector('.my-qc-task-panel')
const filterBlock = pageRef.value?.querySelector('.filter-block')
const pagerBar = pageRef.value?.querySelector('.list-pager-bar')
if (panel) pageResizeObserver.observe(panel)
if (filterBlock) pageResizeObserver.observe(filterBlock)
if (pagerBar) pageResizeObserver.observe(pagerBar)
}
function unbindPageHeightListeners() {
window.removeEventListener('resize', updatePageHeight)
pageResizeObserver?.disconnect()
pageResizeObserver = undefined
}
onMounted(() => {
nextTick(bindPageHeightListeners)
})
onUnmounted(unbindPageHeightListeners)
const searchForm = reactive({
keyword: '',
taskStatus: '',
taskType: '',
qcBizType: ''
})
const appliedFilters = reactive({
keyword: '',
taskStatus: '',
taskType: '',
qcBizType: ''
})
const refreshToken = ref(0)
const pagination = reactive({
page: 1,
pageSize: 30,
total: 0
})
function syncAppliedFilters() {
appliedFilters.keyword = searchForm.keyword.trim()
appliedFilters.taskStatus = searchForm.taskStatus
appliedFilters.taskType = searchForm.taskType
appliedFilters.qcBizType = searchForm.qcBizType
}
function handleSearch() {
syncAppliedFilters()
pagination.page = 1
refreshToken.value += 1
}
function handleReset() {
searchForm.keyword = ''
searchForm.taskStatus = ''
searchForm.taskType = ''
searchForm.qcBizType = ''
handleSearch()
}
function handlePageChange() {
refreshToken.value += 1
}
function handleSizeChange() {
pagination.page = 1
refreshToken.value += 1
}
</script>
<style scoped lang="scss">
.my-qc-task-page {
display: flex;
flex-direction: column;
box-sizing: border-box;
.my-qc-task-panel {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.my-qc-task-list {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
padding-top: 12px;
}
.my-qc-task-cards {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
}
</style>
@@ -0,0 +1,535 @@
<!-- 质检工单 · 卡片列表(工单大卡片 + 任务小卡片) -->
<template>
<div class="list-body list-wrap qc-work-order-cards-body">
<div v-loading="loading" class="qc-work-order-cards-wrap">
<article v-for="(row, idx) in rows" :key="row.id" class="qc-work-order-card">
<header class="qc-work-order-card__head">
<div class="qc-work-order-card__head-main">
<div class="qc-work-order-card__title-row">
<span class="qc-work-order-card__index">{{ cardIndex(idx) }}</span>
<span class="qc-work-order-card__no">{{ row.workOrderNo || '—' }}</span>
<el-tag :type="workOrderStatusTagType(row.workOrderStatus)" size="small">
{{ workOrderStatusLabel(row.workOrderStatus) }}
</el-tag>
</div>
<dl class="qc-work-order-card__meta">
<template v-if="activeTypeTab === 'outbound'">
<div class="qc-work-order-card__meta-item">
<dt>业务类型</dt>
<dd>
<el-tag
:type="
row.bizType === 'pilot_service'
? 'primary'
: row.bizType === 'clinical_application'
? 'warning'
: 'info'
"
size="small"
>
{{ row.bizTypeLabel || '—' }}
</el-tag>
</dd>
</div>
<div class="qc-work-order-card__meta-item">
<dt>申请单号</dt>
<dd>{{ row.applicationNo || '—' }}</dd>
</div>
<div class="qc-work-order-card__meta-item">
<dt>用户名称</dt>
<dd>{{ row.patientName || '—' }}</dd>
</div>
<div class="qc-work-order-card__meta-item">
<dt>第几次</dt>
<dd>{{ formatOutboundScheduleSortNo(row) }}</dd>
</div>
<div class="qc-work-order-card__meta-item">
<dt>总次数</dt>
<dd>{{ formatOutboundScheduleTotalCount(row) }}</dd>
</div>
<div class="qc-work-order-card__meta-item">
<dt>申请单名称</dt>
<dd>{{ row.applicationName || '—' }}</dd>
</div>
<div class="qc-work-order-card__meta-item">
<dt>渠道名称</dt>
<dd>{{ row.channelName || '—' }}</dd>
</div>
<div class="qc-work-order-card__meta-item">
<dt>输注时间</dt>
<dd>{{ row.infusionTime || '—' }}</dd>
</div>
<div class="qc-work-order-card__meta-item">
<dt>来源类型</dt>
<dd>
<el-tag
:type="row.receptionSource === 'research_donation' ? 'warning' : 'success'"
size="small"
>
{{ row.receptionSourceLabel || receptionSourceLabel(row.receptionSource || '') }}
</el-tag>
</dd>
</div>
</template>
<template v-else-if="activeTypeTab === 'temporary'">
<div class="qc-work-order-card__meta-item">
<dt>业务类型</dt>
<dd>
<el-tag :type="qcBizTypeTagType('temporary')" size="small">
{{ row.qcBizTypeLabel || qcBizTypeLabel('temporary') }}
</el-tag>
</dd>
</div>
<div class="qc-work-order-card__meta-item">
<dt>产品编码</dt>
<dd>{{ formatTempCellProductField(row, row.cellProductCode) }}</dd>
</div>
<div class="qc-work-order-card__meta-item">
<dt>产品名称</dt>
<dd>{{ formatTempCellProductField(row, row.cellProductName) }}</dd>
</div>
<div class="qc-work-order-card__meta-item">
<dt>产品类型</dt>
<dd>{{ formatTempCellProductField(row, row.cellProductTypeName) }}</dd>
</div>
</template>
<template v-else>
<div class="qc-work-order-card__meta-item">
<dt>样本编码</dt>
<dd>{{ formatTempEmptyField(row, row.sampleCode) }}</dd>
</div>
<div class="qc-work-order-card__meta-item">
<dt>实验室入库编码</dt>
<dd>{{ formatTempEmptyField(row, row.labStorageCode) }}</dd>
</div>
<div class="qc-work-order-card__meta-item">
<dt>来源类型</dt>
<dd>
<span v-if="shouldShowTempEmptyField(row, row.receptionSource)">—</span>
<el-tag
v-else
:type="row.receptionSource === 'research_donation' ? 'warning' : 'success'"
size="small"
>
{{ row.receptionSourceLabel || receptionSourceLabel(row.receptionSource || '') }}
</el-tag>
</dd>
</div>
<div class="qc-work-order-card__meta-item">
<dt>协议状态</dt>
<dd>
<span v-if="shouldShowTempEmptyField(row, row.protocolStatus)">—</span>
<el-tag
v-else
:type="
protocolStatusTagTypeBySource(
String(row.protocolStatus || ''),
row.receptionSource || 'cell_storage'
)
"
size="small"
>
{{
protocolStatusLabelBySource(
String(row.protocolStatus || ''),
row.receptionSource || 'cell_storage'
)
}}
</el-tag>
</dd>
</div>
<div class="qc-work-order-card__meta-item">
<dt>样本类型</dt>
<dd>{{ row.sampleTypeName || '—' }}</dd>
</div>
<div class="qc-work-order-card__meta-item">
<dt>接收时间</dt>
<dd>{{ row.receptionTime || '—' }}</dd>
</div>
<div class="qc-work-order-card__meta-item">
<dt>接收经手人</dt>
<dd>{{ row.handlerName || '—' }}</dd>
</div>
</template>
<div class="qc-work-order-card__meta-item">
<dt>检测方式</dt>
<dd>{{ formatDetectMethods(row) }}</dd>
</div>
<div class="qc-work-order-card__meta-item">
<dt>{{ activeTypeTab === 'outbound' ? '创建时间' : '创建时间' }}</dt>
<dd>{{ row.createTime || '—' }}</dd>
</div>
<div v-if="row.remark" class="qc-work-order-card__meta-item qc-work-order-card__meta-item--wide">
<dt>备注</dt>
<dd>{{ row.remark }}</dd>
</div>
</dl>
</div>
<div v-if="activeTypeTab !== 'all'" class="qc-work-order-card__ops">
<el-button
v-if="canConfigureWorkOrder(row)"
class="btn-op-item primary"
type="primary"
@click="emit('configure', row)"
>
<el-icon><Setting /></el-icon
>{{ row.workOrderStatus !== 'dpz' ? '调整检测' : '配置检测' }}
</el-button>
<el-button v-else class="btn-op-item primary" @click="emit('view', row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-dropdown
v-if="hasMoreWorkOrderActions(row)"
trigger="hover"
@command="(cmd) => emit('command', 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="canConfigureWorkOrder(row)" command="view">
<el-icon><View /></el-icon>查看
</el-dropdown-item>
<el-dropdown-item
v-if="canEditTemporaryWorkOrder(row) || canEditRemarkWorkOrder(row)"
command="edit"
>
<el-icon><EditPen /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item v-if="canDeleteWorkOrder(row)" command="delete" divided>
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</header>
<section v-if="activeTypeTab !== 'all'" class="qc-work-order-card__tasks">
<QcWorkOrderTaskSubList
:work-order="row"
:refresh-token="taskRefreshToken"
@changed="emit('tasks-changed')"
/>
</section>
</article>
<el-empty
v-if="!loading && !rows.length"
class="qc-work-order-cards-empty"
description="暂无质检工单"
:image-size="72"
/>
</div>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="page"
:page-size="pageSize"
:total="total"
layout="total, sizes, prev, pager, next"
@size-change="(v) => emit('page-size-change', v)"
@current-change="(v) => emit('page-change', v)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { Setting, View, More, EditPen, Delete } from '@element-plus/icons-vue'
import type { QcWorkOrder } from '@/api/qc-work-order'
import {
workOrderStatusLabel,
receptionSourceLabel,
qcBizTypeLabel
} from '@/utils/qc-labels'
import {
protocolStatusLabel,
protocolStatusTagType,
donationStatusLabel,
donationStatusTagType,
qcBizTypeTagType
} from '@/utils/status-tag-registry'
import type { TagType } from '@/utils/status-tag-registry'
import QcWorkOrderTaskSubList from './QcWorkOrderTaskSubList.vue'
defineOptions({ name: 'QcWorkOrderCardList' })
type WorkOrderTypeTab = 'inbound' | 'temporary' | 'outbound' | 'all'
const props = defineProps<{
rows: QcWorkOrder[]
loading: boolean
activeTypeTab: WorkOrderTypeTab
page: number
pageSize: number
total: number
taskRefreshToken: number
}>()
const emit = defineEmits<{
configure: [row: QcWorkOrder]
view: [row: QcWorkOrder]
command: [cmd: string, row: QcWorkOrder]
'tasks-changed': []
'page-change': [page: number]
'page-size-change': [size: number]
}>()
function protocolStatusLabelBySource(status: string, source: string): string {
return source === 'research_donation' ? donationStatusLabel(status) : protocolStatusLabel(status)
}
function protocolStatusTagTypeBySource(status: string, source: string): TagType {
return source === 'research_donation' ? donationStatusTagType(status) : protocolStatusTagType(status)
}
function shouldShowTempEmptyField(row: QcWorkOrder, value?: string | null) {
return row.workOrderType === 'temporary' && !String(value || '').trim()
}
function formatTempEmptyField(row: QcWorkOrder, value?: string | null) {
return shouldShowTempEmptyField(row, value) ? '—' : String(value || '')
}
function formatTempCellProductField(row: QcWorkOrder, value?: string | null) {
if (row.workOrderType !== 'temporary') return '—'
return String(value || '').trim() || '—'
}
function formatOutboundScheduleSortNo(row: Record<string, unknown>) {
const n = row.sortNo ?? row.orderNum
if (n == null || n === '' || Number(n) <= 0) return '—'
return `第${Number(n)}次`
}
function formatOutboundScheduleTotalCount(row: Record<string, unknown>) {
const n = row.totalCourseCount
if (n == null || n === '' || Number(n) <= 0) return '—'
return `共${Number(n)}次`
}
function formatDetectMethods(row: QcWorkOrder) {
const parts: string[] = []
if (row.hasLab) parts.push('实验室')
if (row.hasThirdParty) parts.push('第三方')
return parts.length ? parts.join(' / ') : '—'
}
function workOrderStatusTagType(status: string): TagType {
if (status === 'ywc') return 'success'
if (status === 'ypz') return 'warning'
if (status === 'yzf') return 'danger'
return 'info'
}
function cardIndex(i: number) {
return (props.page - 1) * props.pageSize + i + 1
}
function canConfigureWorkOrder(_row: QcWorkOrder) {
return true
}
function canEditTemporaryWorkOrder(row: QcWorkOrder) {
return (
props.activeTypeTab === 'temporary' ||
(props.activeTypeTab === 'all' && row.workOrderType === 'temporary')
)
}
function canEditRemarkWorkOrder(row: QcWorkOrder) {
if (props.activeTypeTab === 'inbound') return true
if (props.activeTypeTab === 'all' && row.workOrderType !== 'temporary') return true
if (
props.activeTypeTab === 'outbound' &&
row.workOrderStatus !== 'ywc' &&
row.workOrderStatus !== 'yzf'
) {
return true
}
return false
}
function canDeleteWorkOrder(row: QcWorkOrder) {
return (
props.activeTypeTab !== 'outbound' &&
props.activeTypeTab !== 'all' &&
row.workOrderStatus !== 'ywc'
)
}
function hasMoreWorkOrderActions(row: QcWorkOrder) {
if (canConfigureWorkOrder(row)) return true
return (
canEditTemporaryWorkOrder(row) ||
canEditRemarkWorkOrder(row) ||
canDeleteWorkOrder(row)
)
}
</script>
<style scoped lang="scss">
.qc-work-order-cards-body {
border-top: 1px solid var(--mint-border, #e2e8f0);
}
.qc-work-order-cards-wrap {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(480px, 1fr));
gap: 14px;
min-height: 120px;
padding: 16px;
}
.qc-work-order-card {
display: flex;
flex-direction: column;
height: 100%;
min-width: 0;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
overflow: hidden;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
&:hover {
border-color: #a7f3d0;
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.08);
}
}
.qc-work-order-card__head {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid #e2e8f0;
background: #fafafa;
}
.qc-work-order-card__head-main {
min-width: 0;
}
.qc-work-order-card__title-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.qc-work-order-card__index {
flex-shrink: 0;
font-size: 12px;
color: #94a3b8;
min-width: 20px;
}
.qc-work-order-card__no {
font-size: 15px;
font-weight: 600;
color: #1e293b;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
word-break: break-all;
}
.qc-work-order-card__meta {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 8px 12px;
margin: 0;
}
.qc-work-order-card__meta-item {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
dt {
margin: 0;
font-size: 11px;
line-height: 1.35;
color: #94a3b8;
}
dd {
margin: 0;
font-size: 13px;
line-height: 1.4;
color: #334155;
word-break: break-word;
}
&--wide {
grid-column: 1 / -1;
}
}
.qc-work-order-card__ops {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-shrink: 0;
}
.qc-work-order-card__tasks {
display: flex;
flex-direction: column;
flex: 0 1 auto;
padding: 12px 16px 14px;
background: #f8fafc;
}
.qc-work-order-card__tasks:has(.qc-task-cards-wrap--empty) {
flex: 1 1 auto;
min-height: 232px;
}
.qc-work-order-cards-empty {
grid-column: 1 / -1;
padding: 24px 0;
}
.qc-work-order-card__tasks :deep(.qc-work-order-task-sublist) {
padding: 0;
background: transparent;
}
.qc-work-order-card__tasks:has(.qc-task-cards-wrap--empty) :deep(.qc-work-order-task-sublist) {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.qc-work-order-card__tasks:has(.qc-task-cards-wrap--empty) :deep(.qc-work-order-task-sublist__panel) {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.qc-work-order-card__tasks:has(.qc-task-cards-wrap--empty) :deep(.qc-task-cards-wrap--empty) {
flex: 1;
min-height: 204px;
}
.qc-work-order-card__tasks:has(.qc-task-cards-wrap--empty) :deep(.qc-work-order-task-sublist__empty) {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0;
}
.qc-work-order-card__tasks :deep(.qc-task-cards-wrap:not(.qc-task-cards-wrap--empty)) {
padding: 0;
}
</style>
@@ -1,47 +1,75 @@
<!-- 质检工单展开行 · 质检任务子列表 -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="qc-work-order-task-sublist">
<div
class="qc-work-order-task-sublist"
:class="{ 'qc-work-order-task-sublist--mine': listMode === 'mine' }"
>
<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"
<div
v-if="tableData.length"
class="qc-task-cards-wrap"
:class="{ 'qc-task-cards-wrap--mine': listMode === 'mine' }"
>
<div v-if="listMode !== 'mine'" class="qc-work-order-task-sublist__head">
<div class="qc-work-order-task-sublist__head-title">质检任务</div>
<div class="qc-work-order-task-sublist__head-extra">
<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"
>
{{ row.conclusionLabel || qcTaskConclusionLabel(row.conclusion) }}
</el-tag>
<el-icon><RefreshLeft /></el-icon>
</el-button>
</div>
<div class="qc-work-order-task-sublist__ops">
</div>
<div class="qc-task-cards-row">
<div
v-for="(row, idx) in tableData"
:key="row.id || idx"
class="qc-task-card"
@dblclick="handleView(row)"
>
<div class="qc-task-card__name">{{ row.itemName || row.taskNo || '—' }}</div>
<div class="qc-task-card__code">{{ row.taskNo || '—' }}</div>
<div class="qc-task-card__tags">
<el-tag :type="taskStatusTagType(row.taskStatus)" size="small">
{{ taskStatusLabel(row.taskStatus) }}
</el-tag>
<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>
<el-tag v-else type="info" size="small">待结论</el-tag>
</div>
<div v-if="listMode === 'mine'" class="qc-task-card__context">
<span v-if="row.workOrderNo" class="qc-task-card__context-item"
>工单 {{ row.workOrderNo }}</span
>
<span v-if="row.sampleCode" class="qc-task-card__context-item"
>样本 {{ row.sampleCode }}</span
>
<el-tag
v-if="row.qcBizType"
:type="qcBizTypeTagType(row.qcBizType)"
size="small"
>
{{ row.qcBizTypeLabel || qcBizTypeLabel(row.qcBizType) }}
</el-tag>
</div>
<div class="qc-task-card__assignees">
{{ row.assigneeUserNames ? `负责人:${row.assigneeUserNames}` : '负责人:—' }}
</div>
<div class="qc-work-order-task-sublist__ops">
<template v-if="hasPrimaryTaskActions(row)">
<el-button
v-if="canStartTask(row)"
@@ -92,22 +120,33 @@
</el-dropdown>
</div>
</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"
>
<el-icon><RefreshLeft /></el-icon>
</el-button>
<div
v-else-if="!loading"
class="qc-task-cards-wrap qc-task-cards-wrap--empty"
:class="{ 'qc-task-cards-wrap--mine': listMode === 'mine' }"
>
<div v-if="listMode !== 'mine'" class="qc-work-order-task-sublist__head">
<div class="qc-work-order-task-sublist__head-title">质检任务</div>
<div class="qc-work-order-task-sublist__head-extra">
<span class="qc-work-order-task-sublist__count">共 0 项任务</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>
<el-empty
class="qc-work-order-task-sublist__empty"
description="暂无任务,请先在工单行「配置检测」"
:description="
listMode === 'mine' ? '暂无指派给您的质检任务' : '暂无任务,请先在工单行「配置检测」'
"
:image-size="56"
/>
</div>
@@ -468,12 +507,15 @@
} from '@/components/business/protocol-finance-confirm-fields/finance-attachments'
import {
fetchQcTaskList,
fetchMyQcTaskList,
fetchQcTaskDetail,
fetchStartQcTask,
fetchDeleteQcTask,
type QcTask,
type QcTaskDetail
type QcTaskDetail,
type QcTaskOperateScope
} from '@/api/qc-task'
import { useUserStore } from '@/store/modules/user'
import { fetchOutboundQcDetail, type OutboundQcDetail } from '@/api/qc-work-order'
import {
fetchQcTaskResults,
@@ -528,19 +570,54 @@
defineOptions({ name: 'QcWorkOrderTaskSubList' })
const props = defineProps<{
workOrder: Record<string, any>
/** 父级配置检测成功后递增,用于刷新已展开子列表 */
refreshToken?: number
}>()
/** 我的任务页一次加载上限(后端 pageSize 最大 100) */
const MINE_TASK_MAX_PAGE_SIZE = 100
type QcTaskMineFilters = {
keyword?: string
taskType?: string
taskStatus?: string
qcBizType?: string
}
const props = withDefaults(
defineProps<{
workOrder?: Record<string, any>
/** workOrder:工单展开行;mine:我的质检任务 */
listMode?: 'workOrder' | 'mine'
mineFilters?: QcTaskMineFilters
page?: number
pageSize?: number
/** 父级配置检测成功后递增,用于刷新已展开子列表 */
refreshToken?: number
}>(),
{
listMode: 'workOrder',
page: 1,
pageSize: 30
}
)
const emit = defineEmits<{
changed: []
'update:total': [number]
}>()
const userStore = useUserStore()
const listMode = computed(() => props.listMode || 'workOrder')
const operateScope = computed<QcTaskOperateScope>(() =>
listMode.value === 'mine' ? 'mine' : 'manage'
)
const tableData = ref<QcTask[]>([])
const loading = ref(false)
function isCurrentUserAssignee(row: QcTask) {
const uid = String(userStore.info?.id || userStore.info?.userId || '').trim()
if (!uid) return false
return (row.assigneeUserIds || []).some((id) => String(id) === uid)
}
const resultVisible = ref(false)
const resultLoading = ref(false)
const resultTaskId = ref('')
@@ -668,12 +745,16 @@
/** 是否存在开始 / 填写结果主操作 */
function hasPrimaryTaskActions(row: QcTask) {
return canStartTask(row) || canFillResult(row)
const base = canStartTask(row) || canFillResult(row)
if (listMode.value === 'mine') {
return base && isCurrentUserAssignee(row)
}
return base
}
/** 待执行时可删除 */
/** 待执行时可删除(仅工单管理视图) */
function canDeleteTask(row: QcTask) {
return row.taskStatus === 'dzx'
return listMode.value === 'workOrder' && row.taskStatus === 'dzx'
}
/** 更多菜单是否展示 */
@@ -1094,6 +1175,7 @@
value: d.conclusionLabel || qcTaskConclusionLabel(d.conclusion || ''),
tagType: d.conclusion === 'tg' ? 'success' : d.conclusion === 'btg' ? 'danger' : 'info'
},
{ label: '负责人', value: d.assigneeUserNames || '—' },
{ label: '完成时间', value: d.completedTime || '—' },
{ label: '创建时间', value: d.createTime || '—' },
{ label: '备注', value: d.remark || '—', span: true }
@@ -1120,6 +1202,25 @@
}
async function loadData() {
if (listMode.value === 'mine') {
loading.value = true
try {
const f = props.mineFilters || {}
const res = await fetchMyQcTaskList({
page: props.page,
pageSize: Math.min(props.pageSize, MINE_TASK_MAX_PAGE_SIZE),
keyword: f.keyword,
taskType: f.taskType,
taskStatus: f.taskStatus,
qcBizType: f.qcBizType
})
tableData.value = res.list || []
emit('update:total', res.total || 0)
} finally {
loading.value = false
}
return
}
const workOrderId = props.workOrder?.id
if (!workOrderId) {
tableData.value = []
@@ -1135,7 +1236,7 @@
}
async function handleStart(row: QcTask) {
await fetchStartQcTask(row.id)
await fetchStartQcTask(row.id, operateScope.value)
ElMessage.success('已开始')
handleTaskMutationSuccess()
}
@@ -1301,7 +1402,7 @@
remark: mu.remark?.trim() || undefined
}))
}
await fetchCompleteTaskWithResult(resultTaskId.value, data)
await fetchCompleteTaskWithResult(resultTaskId.value, data, operateScope.value)
ElMessage.success(resultTaskStatus.value === 'ywc' ? '已保存' : '已完成')
resultVisible.value = false
handleTaskMutationSuccess()
@@ -1311,7 +1412,18 @@
}
watch(
() => [props.workOrder?.id, props.refreshToken] as const,
() =>
listMode.value === 'mine'
? ([
props.page,
props.pageSize,
props.refreshToken,
props.mineFilters?.keyword,
props.mineFilters?.taskType,
props.mineFilters?.taskStatus,
props.mineFilters?.qcBizType
] as const)
: ([props.workOrder?.id, props.refreshToken] as const),
() => {
loadData()
},
@@ -1324,6 +1436,57 @@
padding: 8px 12px 10px 40px;
background: #f8fafc;
text-align: left;
&--mine {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding: 0;
background: transparent;
.qc-work-order-task-sublist__panel {
flex: 1;
min-height: 0;
height: 100%;
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.qc-task-cards-wrap--mine {
display: flex;
flex-direction: column;
gap: 0;
width: 100%;
padding: 0 20px 4px;
box-sizing: border-box;
}
.qc-task-cards-row {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 12px;
align-content: start;
width: 100%;
}
.qc-task-card {
width: 100%;
max-width: none;
flex: unset;
}
.qc-task-cards-wrap--empty {
display: flex;
flex: 1;
min-height: 100%;
align-items: center;
justify-content: center;
padding: 0 20px;
box-sizing: border-box;
}
}
}
.qc-work-order-task-sublist__panel {
@@ -1332,18 +1495,43 @@
.qc-task-cards-wrap {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.qc-work-order-task-sublist__summary {
flex: 0 0 auto;
.qc-task-cards-row {
display: flex;
flex-wrap: nowrap;
align-items: stretch;
gap: 8px;
overflow-x: auto;
padding-bottom: 4px;
scrollbar-width: thin;
}
.qc-work-order-task-sublist__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 28px;
}
.qc-work-order-task-sublist__head-title {
padding-left: 10px;
border-left: 3px solid #10b981;
font-size: 12px;
font-weight: 600;
color: #64748b;
line-height: 1.35;
}
.qc-work-order-task-sublist__head-extra {
display: inline-flex;
align-items: center;
gap: 2px;
padding-top: 8px;
min-height: 34px;
flex-shrink: 0;
}
.qc-work-order-task-sublist__count {
@@ -1364,15 +1552,40 @@
}
}
.qc-task-cards-wrap--empty {
align-items: center;
.qc-task-cards-wrap--empty:not(.qc-task-cards-wrap--mine) {
flex: 1;
min-height: 204px;
}
.qc-work-order-task-sublist__summary {
padding-top: 0;
min-height: auto;
.qc-task-cards-wrap--empty {
flex-direction: column;
align-items: stretch;
.qc-work-order-task-sublist__head {
width: 100%;
flex-shrink: 0;
}
}
.qc-task-cards-wrap--empty.qc-task-cards-wrap--mine {
min-height: 0;
}
.qc-task-cards-wrap--empty:not(.qc-task-cards-wrap--mine) .qc-work-order-task-sublist__empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
align-self: stretch;
padding: 0;
}
.qc-task-cards-wrap--empty.qc-task-cards-wrap--mine .qc-work-order-task-sublist__empty {
align-self: center;
padding: 8px 0 4px;
}
.qc-work-order-task-sublist__empty {
flex: 1;
min-width: 200px;
@@ -1390,6 +1603,8 @@
flex: 0 0 248px;
width: 248px;
max-width: 248px;
min-height: 188px;
height: auto;
padding: 8px 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
@@ -1403,17 +1618,9 @@
}
}
.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;
min-height: 36px;
font-size: 13px;
font-weight: 600;
color: #1e293b;
@@ -1425,12 +1632,17 @@
word-break: break-word;
}
.qc-task-card__meta {
.qc-task-card__tags {
display: flex;
flex-wrap: wrap;
flex-wrap: nowrap;
align-items: center;
gap: 4px 6px;
min-width: 0;
gap: 4px;
min-height: 22px;
overflow: hidden;
:deep(.el-tag) {
flex-shrink: 0;
}
}
.qc-task-card__code {
@@ -1444,11 +1656,37 @@
white-space: nowrap;
}
.qc-task-card__context {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 6px;
font-size: 11px;
color: #64748b;
line-height: 1.35;
}
.qc-task-card__context-item {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.qc-task-card__assignees {
min-height: 16px;
font-size: 11px;
color: #64748b;
line-height: 1.35;
word-break: break-word;
}
.qc-work-order-task-sublist__ops {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-top: auto;
padding-top: 2px;
}
File diff suppressed because it is too large. Load diff