提交
This commit is contained in:
1 parent
04d462ad43
commit
7e9155fef1
7 files changed
+1754
-68
No files matched your search
@@ -0,0 +1,137 @@
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
buildTaskListDisplayItems,
|
||||
pickSharedRemarkAndAttachments,
|
||||
companyGroupProgress,
|
||||
uniqueAssigneeNames
|
||||
} from './qc-company-task-group'
|
||||
|
||||
type TestQcTask = {
|
||||
id: string
|
||||
taskType: string
|
||||
taskNo?: string
|
||||
itemCode?: string
|
||||
itemName?: string
|
||||
taskStatus?: string
|
||||
workOrderId?: string
|
||||
companyId?: string
|
||||
companyName?: string
|
||||
remark?: string
|
||||
thirdPartyAttachments?: string
|
||||
updateTime?: string
|
||||
assigneeUserNames?: string
|
||||
attachmentFiles?: Array<{ fileUrl: string; fileName?: string; fileType?: string }>
|
||||
}
|
||||
|
||||
function task(partial: Partial<TestQcTask> & Pick<TestQcTask, 'id' | 'taskType'>): TestQcTask {
|
||||
return {
|
||||
taskNo: 'T1',
|
||||
itemCode: 'I',
|
||||
itemName: 'Item',
|
||||
taskStatus: 'dzx',
|
||||
workOrderId: 'wo1',
|
||||
...partial
|
||||
}
|
||||
}
|
||||
|
||||
describe('buildTaskListDisplayItems', () => {
|
||||
it('keeps lab tasks as single cards and groups third-party by companyId', () => {
|
||||
const tasks = [
|
||||
task({ id: 'l1', taskType: 'sysz', itemName: '无菌' }),
|
||||
task({
|
||||
id: 't1',
|
||||
taskType: 'dsfjc',
|
||||
companyId: 'c1',
|
||||
companyName: '金域',
|
||||
itemName: '乙肝'
|
||||
}),
|
||||
task({
|
||||
id: 't2',
|
||||
taskType: 'dsfjc',
|
||||
companyId: 'c1',
|
||||
companyName: '金域',
|
||||
itemName: '丙肝'
|
||||
}),
|
||||
task({
|
||||
id: 't3',
|
||||
taskType: 'dsfjc',
|
||||
companyId: 'c2',
|
||||
companyName: '迪安',
|
||||
itemName: 'HIV'
|
||||
})
|
||||
]
|
||||
const items = buildTaskListDisplayItems(tasks)
|
||||
assert.equal(items.length, 3)
|
||||
assert.equal(items[0].kind, 'lab')
|
||||
assert.equal(items[0].kind === 'lab' && items[0].task.id, 'l1')
|
||||
assert.equal(items[1].kind, 'company')
|
||||
if (items[1].kind === 'company') {
|
||||
assert.equal(items[1].companyId, 'c1')
|
||||
assert.equal(items[1].tasks.length, 2)
|
||||
}
|
||||
assert.equal(items[2].kind, 'company')
|
||||
if (items[2].kind === 'company') {
|
||||
assert.equal(items[2].companyId, 'c2')
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to single lab-like card when third-party has empty companyId', () => {
|
||||
const tasks = [
|
||||
task({ id: 't0', taskType: 'dsfjc', companyId: '', companyName: '', itemName: '孤儿' })
|
||||
]
|
||||
const items = buildTaskListDisplayItems(tasks)
|
||||
assert.equal(items.length, 1)
|
||||
assert.equal(items[0].kind, 'orphan-third')
|
||||
})
|
||||
})
|
||||
|
||||
describe('companyGroupProgress', () => {
|
||||
it('counts ywc vs total', () => {
|
||||
const p = companyGroupProgress([
|
||||
task({ id: 'a', taskType: 'dsfjc', taskStatus: 'ywc' }),
|
||||
task({ id: 'b', taskType: 'dsfjc', taskStatus: 'dzx' })
|
||||
])
|
||||
assert.deepEqual(p, { completed: 1, total: 2 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('pickSharedRemarkAndAttachments', () => {
|
||||
it('picks latest non-empty remark and attachments by updateTime', () => {
|
||||
const picked = pickSharedRemarkAndAttachments([
|
||||
task({
|
||||
id: 'a',
|
||||
taskType: 'dsfjc',
|
||||
remark: '旧备注',
|
||||
thirdPartyAttachments: 'http://a.pdf',
|
||||
updateTime: '2026-08-01 10:00:00',
|
||||
attachmentFiles: [{ fileUrl: 'http://a.pdf', fileName: 'a.pdf' }]
|
||||
}),
|
||||
task({
|
||||
id: 'b',
|
||||
taskType: 'dsfjc',
|
||||
remark: '新备注',
|
||||
thirdPartyAttachments: '',
|
||||
updateTime: '2026-08-28 12:00:00',
|
||||
attachmentFiles: []
|
||||
})
|
||||
])
|
||||
assert.equal(picked.remark, '新备注')
|
||||
assert.equal(picked.thirdPartyAttachments, 'http://a.pdf')
|
||||
assert.equal(picked.attachmentFiles?.length, 1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('uniqueAssigneeNames', () => {
|
||||
it('dedupes assignee names split by comma or顿号', () => {
|
||||
const names = uniqueAssigneeNames([
|
||||
task({ id: 'a', taskType: 'dsfjc', assigneeUserNames: '张三,李四' }),
|
||||
task({ id: 'b', taskType: 'dsfjc', assigneeUserNames: '李四、王五' })
|
||||
])
|
||||
assert.equal(names, '张三、李四、王五')
|
||||
})
|
||||
|
||||
it('returns em dash when no assignees', () => {
|
||||
assert.equal(uniqueAssigneeNames([task({ id: 'a', taskType: 'dsfjc' })]), '—')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { QcTask } from '../api/qc-task'
|
||||
|
||||
export type QcTaskListLabItem = { kind: 'lab'; task: QcTask }
|
||||
export type QcTaskListOrphanThirdItem = { kind: 'orphan-third'; task: QcTask }
|
||||
export type QcTaskListCompanyItem = {
|
||||
kind: 'company'
|
||||
companyId: string
|
||||
companyName: string
|
||||
tasks: QcTask[]
|
||||
}
|
||||
export type QcTaskListDisplayItem =
|
||||
| QcTaskListLabItem
|
||||
| QcTaskListOrphanThirdItem
|
||||
| QcTaskListCompanyItem
|
||||
|
||||
function trimId(v?: string) {
|
||||
return (v || '').trim()
|
||||
}
|
||||
|
||||
/** 自检卡在前(原序);有 companyId 的第三方按首次出现公司顺序分组;无 companyId 降级为 orphan-third */
|
||||
export function buildTaskListDisplayItems(tasks: QcTask[]): QcTaskListDisplayItem[] {
|
||||
const lab: QcTaskListDisplayItem[] = []
|
||||
const orphans: QcTaskListDisplayItem[] = []
|
||||
const companyOrder: string[] = []
|
||||
const companyMap = new Map<string, QcTaskListCompanyItem>()
|
||||
|
||||
for (const t of tasks || []) {
|
||||
if ((t.taskType || '').trim() === 'sysz') {
|
||||
lab.push({ kind: 'lab', task: t })
|
||||
continue
|
||||
}
|
||||
if ((t.taskType || '').trim() !== 'dsfjc') {
|
||||
lab.push({ kind: 'lab', task: t })
|
||||
continue
|
||||
}
|
||||
const cid = trimId(t.companyId)
|
||||
if (!cid) {
|
||||
orphans.push({ kind: 'orphan-third', task: t })
|
||||
continue
|
||||
}
|
||||
let g = companyMap.get(cid)
|
||||
if (!g) {
|
||||
g = {
|
||||
kind: 'company',
|
||||
companyId: cid,
|
||||
companyName: (t.companyName || '').trim() || '第三方公司',
|
||||
tasks: []
|
||||
}
|
||||
companyMap.set(cid, g)
|
||||
companyOrder.push(cid)
|
||||
}
|
||||
g.tasks.push(t)
|
||||
}
|
||||
|
||||
return [...lab, ...companyOrder.map((id) => companyMap.get(id)!), ...orphans]
|
||||
}
|
||||
|
||||
export function companyGroupProgress(tasks: QcTask[]) {
|
||||
const list = tasks || []
|
||||
const completed = list.filter((t) => (t.taskStatus || '').trim() === 'ywc').length
|
||||
return { completed, total: list.length }
|
||||
}
|
||||
|
||||
function timeMs(v?: string) {
|
||||
if (!v) return 0
|
||||
const n = Date.parse(String(v).replace(/-/g, '/'))
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
export function pickSharedRemarkAndAttachments(tasks: QcTask[]): {
|
||||
remark: string
|
||||
thirdPartyAttachments: string
|
||||
attachmentFiles: NonNullable<QcTask['attachmentFiles']>
|
||||
} {
|
||||
const list = [...(tasks || [])].sort((a, b) => timeMs(b.updateTime) - timeMs(a.updateTime))
|
||||
let remark = ''
|
||||
let thirdPartyAttachments = ''
|
||||
let attachmentFiles: NonNullable<QcTask['attachmentFiles']> = []
|
||||
for (const t of list) {
|
||||
if (!remark && (t.remark || '').trim()) remark = (t.remark || '').trim()
|
||||
const urls = (t.thirdPartyAttachments || '').trim()
|
||||
const files = t.attachmentFiles || []
|
||||
if (!thirdPartyAttachments && (urls || files.length)) {
|
||||
thirdPartyAttachments = urls
|
||||
attachmentFiles = files.length ? [...files] : []
|
||||
}
|
||||
if (remark && (thirdPartyAttachments || attachmentFiles.length)) break
|
||||
}
|
||||
return { remark, thirdPartyAttachments, attachmentFiles }
|
||||
}
|
||||
|
||||
export function uniqueAssigneeNames(tasks: QcTask[]): string {
|
||||
const names: string[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const t of tasks || []) {
|
||||
const raw = (t.assigneeUserNames || '').trim()
|
||||
if (!raw) continue
|
||||
for (const part of raw.split(/[,,、]/)) {
|
||||
const n = part.trim()
|
||||
if (!n || seen.has(n)) continue
|
||||
seen.add(n)
|
||||
names.push(n)
|
||||
}
|
||||
}
|
||||
return names.join('、') || '—'
|
||||
}
|
||||
@@ -184,7 +184,6 @@
|
||||
v-model="prodForm[field.key]"
|
||||
clearable
|
||||
:maxlength="field.maxLength"
|
||||
:show-word-limit="!!field.maxLength"
|
||||
/>
|
||||
<DecimalInput
|
||||
v-else-if="field.kind === 'number' || field.kind === 'integer'"
|
||||
|
||||
@@ -0,0 +1,542 @@
|
||||
<!-- 质检第三方 — 按公司聚合的检测结果填写/查看弹窗 -->
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
:title="dialogTitle"
|
||||
:width="dialogWidth"
|
||||
class="dialog-form-layout qc-company-task-result-dialog"
|
||||
align-center
|
||||
modal-class="protocol-dialog-mask"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
@update:model-value="onVisibleChange"
|
||||
>
|
||||
<DialogFormBody :loading="loading">
|
||||
<el-form label-position="top" class="biz-dialog-form">
|
||||
<div
|
||||
v-for="(item, itemIndex) in itemStates"
|
||||
:key="item.taskId"
|
||||
class="company-item-block form-section"
|
||||
>
|
||||
<div class="company-item-block__head">
|
||||
<span class="company-item-block__name">
|
||||
<span class="company-item-block__index">{{ itemIndex + 1 }}.</span>
|
||||
{{ item.itemName || '—' }}
|
||||
</span>
|
||||
<el-tag size="small" :type="taskStatusTagType(item.saveStatus)">
|
||||
{{ taskStatusLabel(item.saveStatus) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="section-title">检测参数</div>
|
||||
<div v-if="!loading && item.params.length === 0" class="company-item-block__empty-params">
|
||||
该项目暂无配置参数,请先在第三方项目中维护检测参数
|
||||
</div>
|
||||
<div class="form-grid-3">
|
||||
<el-form-item
|
||||
v-for="(param, idx) in item.params"
|
||||
:key="param.paramId || param.paramCode || idx"
|
||||
:label="param.paramName + (param.paramUnit ? `(${param.paramUnit})` : '')"
|
||||
>
|
||||
<el-input
|
||||
v-if="param.paramType === 'text'"
|
||||
v-model="param.paramValue"
|
||||
placeholder="请输入"
|
||||
:disabled="readonly"
|
||||
/>
|
||||
<DecimalInput
|
||||
v-else-if="param.paramType === 'number'"
|
||||
:model-value="toResultNumber(param.paramValue)"
|
||||
:precision="2"
|
||||
style="width: 100%"
|
||||
placeholder="请输入"
|
||||
:disabled="readonly"
|
||||
@update:model-value="(v) => setResultNumber(param, v)"
|
||||
/>
|
||||
<el-select
|
||||
v-else-if="param.paramType === 'select'"
|
||||
v-model="param.paramValue"
|
||||
placeholder="请选择"
|
||||
clearable
|
||||
filterable
|
||||
style="width: 100%"
|
||||
:disabled="readonly"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in parseQcParamOptions(param.options)"
|
||||
:key="opt"
|
||||
:label="opt"
|
||||
:value="opt"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input
|
||||
v-else
|
||||
v-model="param.paramValue"
|
||||
placeholder="请输入"
|
||||
:disabled="readonly"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="section-title">检测结论</div>
|
||||
<div class="form-grid-2 result-conclusion-row">
|
||||
<el-form-item label="任务状态" required>
|
||||
<el-radio-group v-model="item.saveStatus" :disabled="readonly">
|
||||
<el-radio value="dzx">待执行</el-radio>
|
||||
<el-radio value="ywc">已完成</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="结论" :required="item.saveStatus === 'ywc'">
|
||||
<el-radio-group v-model="item.conclusion" :disabled="readonly">
|
||||
<el-radio value="" :disabled="item.saveStatus === 'ywc'">待结论</el-radio>
|
||||
<el-radio value="tg">通过</el-radio>
|
||||
<el-radio value="btg">不通过</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section shared-section">
|
||||
<div class="section-title">备注与检测材料</div>
|
||||
<el-form-item label="备注" class="form-col-span-2">
|
||||
<el-input
|
||||
v-model="remark"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="选填"
|
||||
:disabled="readonly"
|
||||
/>
|
||||
</el-form-item>
|
||||
<template v-if="readonly">
|
||||
<el-form-item label="检测材料" class="form-col-span-2">
|
||||
<ProtocolAttachmentReadonly
|
||||
v-if="readonlyAttachmentFiles.length"
|
||||
:files="readonlyAttachmentFiles"
|
||||
@preview="onAttachmentPreview"
|
||||
@download="onAttachmentDownload"
|
||||
/>
|
||||
<span v-else class="shared-section__empty">—</span>
|
||||
</el-form-item>
|
||||
</template>
|
||||
<ProtocolFinanceConfirmFields
|
||||
v-else
|
||||
v-model:finance-remark="remark"
|
||||
v-model:attachments="attachments"
|
||||
:show-remark="false"
|
||||
attachment-label="检测材料"
|
||||
:attachment-required="anyCompleted"
|
||||
upload-folder="qc-third-party"
|
||||
/>
|
||||
</div>
|
||||
</el-form>
|
||||
</DialogFormBody>
|
||||
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="close">
|
||||
<el-icon><Close /></el-icon>{{ readonly ? '关闭' : '取消' }}
|
||||
</el-button>
|
||||
<el-button v-if="!readonly" class="btn-main" type="primary" :loading="saving" @click="submit">
|
||||
<el-icon><Check /></el-icon>保存
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Close, Check } from '@element-plus/icons-vue'
|
||||
import ProtocolFinanceConfirmFields from '@/components/business/protocol-finance-confirm-fields/index.vue'
|
||||
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
|
||||
import DialogFormBody from '@/components/business/dialog-form-body/index.vue'
|
||||
import {
|
||||
financeAttachmentsToUrlString,
|
||||
type FinanceAttachmentItem
|
||||
} from '@/components/business/protocol-finance-confirm-fields/finance-attachments'
|
||||
import { fetchQcTaskDetail, type QcTask, type QcTaskOperateScope } from '@/api/qc-task'
|
||||
import {
|
||||
fetchQcTaskResults,
|
||||
fetchCompleteTaskWithResult,
|
||||
type QcTaskResultItem
|
||||
} from '@/api/qc-task-result'
|
||||
import { parseQcParamOptions } from '@/utils/qc-item-param-options'
|
||||
import { taskStatusLabel, taskStatusTagType } from '@/utils/qc-labels'
|
||||
import { pickSharedRemarkAndAttachments } from '@/utils/qc-company-task-group'
|
||||
import { downloadAttachmentFile } from '@/utils/download-attachment'
|
||||
import {
|
||||
qcPreviewAttachment,
|
||||
qcAttachmentFullUrl,
|
||||
qcAttachmentFilesFromCommaUrls
|
||||
} from './composables/useQcViewAttachmentHelpers'
|
||||
|
||||
defineOptions({ name: 'QcCompanyTaskResultDialog' })
|
||||
|
||||
type ItemState = {
|
||||
taskId: string
|
||||
itemName: string
|
||||
params: QcTaskResultItem[]
|
||||
saveStatus: 'dzx' | 'ywc'
|
||||
conclusion: '' | 'tg' | 'btg'
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
visible: boolean
|
||||
companyName: string
|
||||
tasks: QcTask[]
|
||||
readonly?: boolean
|
||||
operateScope: QcTaskOperateScope
|
||||
}>(),
|
||||
{
|
||||
readonly: false
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean]
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const itemStates = ref<ItemState[]>([])
|
||||
const remark = ref('')
|
||||
const attachments = ref<FinanceAttachmentItem[]>([])
|
||||
/** 防止快速开关/切换任务时旧请求回写覆盖新状态 */
|
||||
let loadSeq = 0
|
||||
|
||||
const dialogWidth = '920px'
|
||||
const dialogTitle = computed(
|
||||
() =>
|
||||
`${props.readonly ? '查看检测结果' : '填写检测结果'} · ${props.companyName || '第三方公司'}`
|
||||
)
|
||||
|
||||
const anyCompleted = computed(() => itemStates.value.some((i) => i.saveStatus === 'ywc'))
|
||||
|
||||
const readonlyAttachmentFiles = computed(() =>
|
||||
attachments.value
|
||||
.filter((a) => a.fileUrl)
|
||||
.map((a) => ({
|
||||
id: a.id,
|
||||
fileUrl: a.fileUrl,
|
||||
fileName: a.fileName,
|
||||
fileType: a.fileType
|
||||
}))
|
||||
)
|
||||
|
||||
function onVisibleChange(v: boolean) {
|
||||
emit('update:visible', v)
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
/** 第三方弹窗仅展示待执行/已完成;其它状态按待执行回显 */
|
||||
function normalizeThirdPartySaveStatus(status?: string): 'dzx' | 'ywc' {
|
||||
return (status || '').trim() === 'ywc' ? 'ywc' : 'dzx'
|
||||
}
|
||||
|
||||
function normalizeConclusion(raw?: string): '' | 'tg' | 'btg' {
|
||||
const c = (raw || '').trim()
|
||||
return c === 'tg' || c === 'btg' ? c : ''
|
||||
}
|
||||
|
||||
function normalizeResultParams(items: QcTaskResultItem[]): QcTaskResultItem[] {
|
||||
return (items || []).map((p) => ({
|
||||
...p,
|
||||
paramType: p.paramType || 'text',
|
||||
paramValue: p.paramValue ?? ''
|
||||
}))
|
||||
}
|
||||
|
||||
function apiFilesToFinanceItems(
|
||||
files?: Array<{ id?: string; fileUrl: string; fileName?: string; fileType?: string }>
|
||||
): FinanceAttachmentItem[] {
|
||||
if (!files?.length) return []
|
||||
return files.map((f, i) => ({
|
||||
id: f.id || `att_${i}`,
|
||||
fileName: f.fileName || '',
|
||||
fileUrl: f.fileUrl || '',
|
||||
fileType: f.fileType || ''
|
||||
}))
|
||||
}
|
||||
|
||||
function toResultNumber(v: string | undefined) {
|
||||
if (v === '' || v === undefined || v === null) return undefined
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) ? n : undefined
|
||||
}
|
||||
|
||||
function setResultNumber(param: QcTaskResultItem, v: number | undefined) {
|
||||
param.paramValue = v === undefined || v === null ? '' : String(v)
|
||||
}
|
||||
|
||||
function hasResultValue(v: unknown) {
|
||||
return v !== '' && v !== undefined && v !== null
|
||||
}
|
||||
|
||||
function onAttachmentPreview(file: Record<string, unknown>) {
|
||||
qcPreviewAttachment(file)
|
||||
}
|
||||
|
||||
function onAttachmentDownload(file: Record<string, unknown>) {
|
||||
downloadAttachmentFile(file, { fullFileUrl: qcAttachmentFullUrl })
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
const seq = ++loadSeq
|
||||
const tasks = props.tasks || []
|
||||
loading.value = true
|
||||
itemStates.value = []
|
||||
remark.value = ''
|
||||
attachments.value = []
|
||||
try {
|
||||
const loaded = await Promise.all(
|
||||
tasks.map(async (task) => {
|
||||
const [detail, results] = await Promise.all([
|
||||
fetchQcTaskDetail(task.id).catch(() => null),
|
||||
fetchQcTaskResults(task.id).catch(() => [] as QcTaskResultItem[])
|
||||
])
|
||||
const merged: QcTask = {
|
||||
...task,
|
||||
...(detail || {}),
|
||||
remark: detail?.remark ?? task.remark,
|
||||
thirdPartyAttachments: detail?.thirdPartyAttachments ?? task.thirdPartyAttachments,
|
||||
attachmentFiles: detail?.attachmentFiles?.length
|
||||
? detail.attachmentFiles
|
||||
: task.attachmentFiles,
|
||||
updateTime: detail?.updateTime ?? task.updateTime,
|
||||
conclusion: detail?.conclusion ?? task.conclusion,
|
||||
taskStatus: detail?.taskStatus || task.taskStatus,
|
||||
itemName: detail?.itemName || task.itemName
|
||||
}
|
||||
const item: ItemState = {
|
||||
taskId: task.id,
|
||||
itemName: merged.itemName || '—',
|
||||
params: normalizeResultParams(results || []),
|
||||
saveStatus: normalizeThirdPartySaveStatus(merged.taskStatus),
|
||||
conclusion: normalizeConclusion(merged.conclusion)
|
||||
}
|
||||
return { item, merged }
|
||||
})
|
||||
)
|
||||
|
||||
if (seq !== loadSeq) return
|
||||
itemStates.value = loaded.map((x) => x.item)
|
||||
const shared = pickSharedRemarkAndAttachments(loaded.map((x) => x.merged))
|
||||
remark.value = shared.remark
|
||||
if (shared.attachmentFiles?.length) {
|
||||
attachments.value = apiFilesToFinanceItems(shared.attachmentFiles)
|
||||
} else if ((shared.thirdPartyAttachments || '').trim()) {
|
||||
attachments.value = apiFilesToFinanceItems(
|
||||
qcAttachmentFilesFromCommaUrls(shared.thirdPartyAttachments) as Array<{
|
||||
id?: string
|
||||
fileUrl: string
|
||||
fileName?: string
|
||||
fileType?: string
|
||||
}>
|
||||
)
|
||||
} else {
|
||||
attachments.value = []
|
||||
}
|
||||
} catch {
|
||||
if (seq !== loadSeq) return
|
||||
ElMessage.error('加载检测结果失败')
|
||||
itemStates.value = []
|
||||
remark.value = ''
|
||||
attachments.value = []
|
||||
} finally {
|
||||
if (seq === loadSeq) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (props.readonly) return
|
||||
if (attachments.value.some((a) => a._uploading)) {
|
||||
ElMessage.warning('附件正在上传,请稍候')
|
||||
return
|
||||
}
|
||||
if (anyCompleted.value && !attachments.value.some((a) => a.fileUrl && !a._uploading)) {
|
||||
ElMessage.warning('请上传检测材料')
|
||||
return
|
||||
}
|
||||
for (const item of itemStates.value) {
|
||||
if (item.saveStatus === 'ywc' && item.conclusion !== 'tg' && item.conclusion !== 'btg') {
|
||||
ElMessage.warning(`请选择「${item.itemName}」的检测结论`)
|
||||
return
|
||||
}
|
||||
for (const p of item.params) {
|
||||
if (p.requiredFlag === '1' && !hasResultValue(p.paramValue)) {
|
||||
ElMessage.warning(`请填写「${item.itemName}」参数「${p.paramName}」`)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const thirdPartyAttachments = financeAttachmentsToUrlString(attachments.value)
|
||||
const remarkVal = remark.value
|
||||
saving.value = true
|
||||
const failures: string[] = []
|
||||
let successCount = 0
|
||||
try {
|
||||
for (const item of itemStates.value) {
|
||||
try {
|
||||
await fetchCompleteTaskWithResult(
|
||||
item.taskId,
|
||||
{
|
||||
conclusion: item.conclusion,
|
||||
results: item.params
|
||||
.filter((p) => hasResultValue(p.paramValue))
|
||||
.map((p) => ({
|
||||
...p,
|
||||
paramValue:
|
||||
p.paramValue === undefined || p.paramValue === null ? '' : String(p.paramValue)
|
||||
})),
|
||||
remark: remarkVal,
|
||||
thirdPartyAttachments,
|
||||
taskStatus: item.saveStatus
|
||||
},
|
||||
props.operateScope
|
||||
)
|
||||
successCount += 1
|
||||
} catch {
|
||||
failures.push(item.itemName || item.taskId)
|
||||
}
|
||||
}
|
||||
if (successCount > 0) {
|
||||
emit('saved')
|
||||
}
|
||||
if (failures.length === 0) {
|
||||
ElMessage.success('已保存')
|
||||
close()
|
||||
} else if (successCount > 0) {
|
||||
ElMessage.warning(`部分保存成功,失败项目:${failures.join('、')}`)
|
||||
} else {
|
||||
ElMessage.error(`保存失败:${failures.join('、')}`)
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.visible, (props.tasks || []).map((t) => t.id).join(','), props.readonly] as const,
|
||||
([visible]) => {
|
||||
if (!visible) {
|
||||
loadSeq += 1
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
void loadAll()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.company-item-block {
|
||||
margin-bottom: 12px;
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__index {
|
||||
margin-right: 2px;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
&__empty-params {
|
||||
text-align: center;
|
||||
padding: 8px 0 12px;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.result-conclusion-row {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.form-section {
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.form-section .section-title {
|
||||
position: relative;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
color: #334155;
|
||||
margin-bottom: 12px;
|
||||
padding: 0 0 8px 8px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 3px;
|
||||
width: 2px;
|
||||
height: 10px;
|
||||
border-radius: 1px;
|
||||
background: #047857;
|
||||
}
|
||||
}
|
||||
|
||||
.shared-section {
|
||||
margin-top: 0;
|
||||
|
||||
&__empty {
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:deep(.protocol-finance-confirm-fields .el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.result-conclusion-row {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(.el-form-item__label) {
|
||||
display: inline-flex;
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.biz-dialog-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -25,84 +25,138 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="qc-task-cards-grid">
|
||||
<div
|
||||
v-for="(row, idx) in tableData"
|
||||
:key="row.id || idx"
|
||||
class="qc-task-card qc-task-card--compact"
|
||||
@dblclick="handleView(row)"
|
||||
>
|
||||
<div class="qc-task-card__line qc-task-card__line--top">
|
||||
<span class="qc-task-card__name">{{ row.itemName || '—' }}</span>
|
||||
<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 :type="qcConclusionDisplayTagType(row.conclusion)" size="small">
|
||||
{{
|
||||
row.conclusionLabel || qcConclusionDisplayLabel(row.conclusion)
|
||||
}}
|
||||
</el-tag>
|
||||
<template v-for="(item, idx) in displayItems">
|
||||
<div
|
||||
v-if="item.kind === 'company'"
|
||||
:key="displayItemKey(item, idx)"
|
||||
class="qc-task-card qc-task-card--compact qc-task-card--company"
|
||||
@click="openCompanyResult(item)"
|
||||
@dblclick="openCompanyResult(item)"
|
||||
>
|
||||
<div class="qc-task-card__line qc-task-card__line--top">
|
||||
<span class="qc-task-card__name">{{ item.companyName }}</span>
|
||||
<div class="qc-task-card__sub">
|
||||
第三方 · {{ item.tasks.length }} 个检测项目
|
||||
</div>
|
||||
<div class="qc-task-card__tags">
|
||||
<el-tag :type="companyProgressTagType(item)" size="small">
|
||||
{{ companyProgressText(item) }}
|
||||
</el-tag>
|
||||
<el-tag type="primary" size="small">第三方检测</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="qc-task-card__line qc-task-card__line--bottom">
|
||||
<span class="qc-task-card__assignees">{{
|
||||
row.assigneeUserNames || '—'
|
||||
}}</span>
|
||||
<div class="qc-work-order-task-sublist__ops">
|
||||
<template v-if="hasPrimaryTaskActions(row)">
|
||||
<div class="qc-task-card__line qc-task-card__line--bottom">
|
||||
<span class="qc-task-card__assignees">{{
|
||||
uniqueAssigneeNames(item.tasks)
|
||||
}}</span>
|
||||
<div class="qc-work-order-task-sublist__ops">
|
||||
<el-button
|
||||
v-if="canStartTask(row)"
|
||||
v-if="canFillCompany(item)"
|
||||
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)"
|
||||
@click.stop="openCompanyResult(item, false)"
|
||||
>
|
||||
<el-icon><EditPen /></el-icon>填写结果
|
||||
</el-button>
|
||||
</template>
|
||||
<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
|
||||
v-else
|
||||
class="btn-op-item primary"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click.stop="openCompanyResult(item, true)"
|
||||
>
|
||||
<el-icon><View /></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 command="delete" divided>
|
||||
<el-icon><Delete /></el-icon>删除
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
:key="displayItemKey(item, idx)"
|
||||
class="qc-task-card qc-task-card--compact"
|
||||
@dblclick="handleView(item.task)"
|
||||
>
|
||||
<div class="qc-task-card__line qc-task-card__line--top">
|
||||
<span class="qc-task-card__name">{{ item.task.itemName || '—' }}</span>
|
||||
<div class="qc-task-card__tags">
|
||||
<el-tag :type="taskStatusTagType(item.task.taskStatus)" size="small">
|
||||
{{ taskStatusLabel(item.task.taskStatus) }}
|
||||
</el-tag>
|
||||
<el-tag :type="taskTypeTagType(item.task.taskType)" size="small">
|
||||
{{ taskTypeLabel(item.task.taskType) }}
|
||||
</el-tag>
|
||||
<el-tag
|
||||
:type="qcConclusionDisplayTagType(item.task.conclusion)"
|
||||
size="small"
|
||||
>
|
||||
{{
|
||||
item.task.conclusionLabel ||
|
||||
qcConclusionDisplayLabel(item.task.conclusion)
|
||||
}}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="qc-task-card__line qc-task-card__line--bottom">
|
||||
<span class="qc-task-card__assignees">{{
|
||||
item.task.assigneeUserNames || '—'
|
||||
}}</span>
|
||||
<div class="qc-work-order-task-sublist__ops">
|
||||
<template v-if="hasPrimaryTaskActions(item.task)">
|
||||
<el-button
|
||||
v-if="canStartTask(item.task)"
|
||||
class="btn-op-item primary"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click.stop="handleStart(item.task)"
|
||||
>
|
||||
<el-icon><VideoPlay /></el-icon>开始
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canFillResult(item.task)"
|
||||
class="btn-op-item primary"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click.stop="openResult(item.task)"
|
||||
>
|
||||
<el-icon><EditPen /></el-icon>填写结果
|
||||
</el-button>
|
||||
</template>
|
||||
<el-button
|
||||
v-else
|
||||
class="btn-op-item primary"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click.stop="handleView(item.task)"
|
||||
>
|
||||
<el-icon><View /></el-icon>查看
|
||||
</el-button>
|
||||
<el-dropdown
|
||||
v-if="hasMoreTaskActions(item.task)"
|
||||
trigger="hover"
|
||||
@command="(cmd) => handleDropdownCommand(cmd, item.task)"
|
||||
>
|
||||
<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(item.task)"
|
||||
command="view"
|
||||
>
|
||||
<el-icon><View /></el-icon>查看
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="delete" divided>
|
||||
<el-icon><Delete /></el-icon>删除
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!loading" class="qc-task-cards-wrap qc-task-cards-wrap--empty">
|
||||
@@ -350,6 +404,15 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<QcCompanyTaskResultDialog
|
||||
v-model:visible="companyDialogVisible"
|
||||
:company-name="companyDialogName"
|
||||
:tasks="companyDialogTasks"
|
||||
:readonly="companyDialogReadonly"
|
||||
:operate-scope="operateScope"
|
||||
@saved="handleTaskMutationSuccess"
|
||||
/>
|
||||
|
||||
<SystemViewDialog
|
||||
v-model:visible="viewVisible"
|
||||
title="任务详情"
|
||||
@@ -582,6 +645,14 @@
|
||||
isOutboundPilotServiceQcTask
|
||||
} from '@/utils/outbound-qc-pilot-view-sections'
|
||||
import type { TagType } from '@/utils/status-tag-registry'
|
||||
import {
|
||||
buildTaskListDisplayItems,
|
||||
companyGroupProgress,
|
||||
uniqueAssigneeNames,
|
||||
type QcTaskListCompanyItem,
|
||||
type QcTaskListDisplayItem
|
||||
} from '@/utils/qc-company-task-group'
|
||||
import QcCompanyTaskResultDialog from './QcCompanyTaskResultDialog.vue'
|
||||
|
||||
/** 根据来源类型返回协议状态标签 */
|
||||
function protocolStatusLabelBySource(status: string, source: string, voidType?: string): string {
|
||||
@@ -631,6 +702,12 @@
|
||||
|
||||
const tableData = ref<QcTask[]>([])
|
||||
const loading = ref(false)
|
||||
const displayItems = computed(() => buildTaskListDisplayItems(tableData.value))
|
||||
|
||||
function displayItemKey(item: QcTaskListDisplayItem, idx: number) {
|
||||
if (item.kind === 'company') return `company_${item.companyId || idx}`
|
||||
return item.task.id || `${item.kind}_${idx}`
|
||||
}
|
||||
|
||||
function isCurrentUserAssignee(row: QcTask) {
|
||||
const uid = String(userStore.info?.id || userStore.info?.userId || '').trim()
|
||||
@@ -638,6 +715,42 @@
|
||||
return (row.assigneeUserIds || []).some((id) => String(id) === uid)
|
||||
}
|
||||
|
||||
function companyProgressText(item: QcTaskListCompanyItem) {
|
||||
const { completed, total } = companyGroupProgress(item.tasks)
|
||||
if (completed === 0) return '待执行'
|
||||
return `${completed}/${total} 已完成`
|
||||
}
|
||||
|
||||
function companyProgressTagType(item: QcTaskListCompanyItem) {
|
||||
const { completed, total } = companyGroupProgress(item.tasks)
|
||||
if (total > 0 && completed === total) return 'success'
|
||||
if (completed > 0) return 'warning'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function canFillCompany(item: QcTaskListCompanyItem) {
|
||||
return item.tasks.some((t) => hasPrimaryTaskActions(t) && canFillResult(t))
|
||||
}
|
||||
|
||||
const companyDialogVisible = ref(false)
|
||||
const companyDialogReadonly = ref(false)
|
||||
const companyDialogTasks = ref<QcTask[]>([])
|
||||
const companyDialogName = ref('')
|
||||
|
||||
function openCompanyResult(item: QcTaskListCompanyItem, forceReadonly?: boolean) {
|
||||
let tasks = item.tasks
|
||||
if (listMode.value === 'mine') {
|
||||
tasks = tasks.filter((t) => isCurrentUserAssignee(t))
|
||||
}
|
||||
if (!tasks.length) return
|
||||
companyDialogName.value = item.companyName
|
||||
companyDialogTasks.value = tasks
|
||||
companyDialogReadonly.value =
|
||||
forceReadonly === true ||
|
||||
!tasks.some((t) => hasPrimaryTaskActions(t) && canFillResult(t))
|
||||
companyDialogVisible.value = true
|
||||
}
|
||||
|
||||
const resultVisible = ref(false)
|
||||
const resultLoading = ref(false)
|
||||
const resultTaskId = ref('')
|
||||
@@ -1588,6 +1701,24 @@
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
&--company {
|
||||
border-color: #a7f3d0;
|
||||
background: #ecfdf5;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary, #10b981);
|
||||
box-shadow: 0 2px 6px rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.qc-task-card__sub {
|
||||
width: 100%;
|
||||
font-size: 11px;
|
||||
color: var(--mint-text-muted, #334155);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.qc-task-card__line {
|
||||
|
||||
Reference in new issue
Block a user