提交
This commit is contained in:
1 parent
28f233c922
commit
038a60b09e
478 files changed
+67530
-51951
No files matched your search
@@ -0,0 +1,94 @@
|
||||
import { fetchAttachmentDownloadUrl } from '@/api/business'
|
||||
|
||||
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
|
||||
|
||||
export function attachmentFullUrl(url: string) {
|
||||
const u = String(url || '').trim()
|
||||
if (!u) return ''
|
||||
if (/^https?:\/\//i.test(u)) return u
|
||||
return minioBaseUrl ? `${minioBaseUrl}/${u.replace(/^\//, '')}` : u
|
||||
}
|
||||
|
||||
export function sanitizeZipPathSegment(value: string, fallback = '未命名') {
|
||||
const cleaned = String(value || '')
|
||||
.replace(/[\\/:*?"<>|]/g, '_')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
return cleaned || fallback
|
||||
}
|
||||
|
||||
export function getAttachmentExtension(file: { fileName?: string; fileUrl?: string }) {
|
||||
const raw = String(file.fileName || file.fileUrl?.split('/').pop() || '')
|
||||
.split('?')[0]
|
||||
.trim()
|
||||
const dot = raw.lastIndexOf('.')
|
||||
if (dot > 0 && dot < raw.length - 1) {
|
||||
return raw.slice(dot + 1).toLowerCase()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function resolveAttachmentFileName(
|
||||
file: { fileName?: string; fileUrl?: string },
|
||||
index = 0
|
||||
) {
|
||||
const name = String(file.fileName || '').trim()
|
||||
if (name) return name
|
||||
const fromUrl = String(file.fileUrl || '')
|
||||
.split('?')[0]
|
||||
.split('/')
|
||||
.pop()
|
||||
if (fromUrl) return fromUrl
|
||||
const ext = getAttachmentExtension(file)
|
||||
return ext ? `附件${index + 1}.${ext}` : `附件${index + 1}`
|
||||
}
|
||||
|
||||
export function ensureUniqueZipEntryName(name: string, used: Set<string>) {
|
||||
if (!used.has(name)) {
|
||||
used.add(name)
|
||||
return name
|
||||
}
|
||||
const dot = name.lastIndexOf('.')
|
||||
const base = dot > 0 ? name.slice(0, dot) : name
|
||||
const ext = dot > 0 ? name.slice(dot) : ''
|
||||
let n = 2
|
||||
while (used.has(`${base}(${n})${ext}`)) n += 1
|
||||
const unique = `${base}(${n})${ext}`
|
||||
used.add(unique)
|
||||
return unique
|
||||
}
|
||||
|
||||
export function formatZipDateStamp(date = new Date()) {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
return `${y}${m}${d}`
|
||||
}
|
||||
|
||||
export async function fetchAttachmentBlob(
|
||||
fileUrl: string,
|
||||
fileName: string,
|
||||
options?: { signal?: AbortSignal }
|
||||
) {
|
||||
const url = String(fileUrl || '').trim()
|
||||
if (!url) return null
|
||||
try {
|
||||
const res = await fetchAttachmentDownloadUrl({ fileUrl: url, fileName })
|
||||
if (res?.downloadUrl) {
|
||||
const response = await fetch(res.downloadUrl, { signal: options?.signal })
|
||||
if (response.ok) return await response.blob()
|
||||
}
|
||||
} catch (err) {
|
||||
if (options?.signal?.aborted) throw err
|
||||
}
|
||||
const href = attachmentFullUrl(url)
|
||||
if (!href) return null
|
||||
try {
|
||||
const response = await fetch(href, { signal: options?.signal })
|
||||
if (response.ok) return await response.blob()
|
||||
} catch (err) {
|
||||
if (options?.signal?.aborted) throw err
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { QcWorkOrder } from '@/api/qc-work-order'
|
||||
import { resolveGateWorkOrderForSampleGroup } from '@/utils/qc-work-order-groups'
|
||||
|
||||
export type InboundSampleSpecialMarkTarget = {
|
||||
receptionSource: 'cell_storage' | 'research_donation'
|
||||
protocolSampleId: string
|
||||
sampleCode: string
|
||||
isSpecial: boolean
|
||||
specialRemark: string
|
||||
}
|
||||
|
||||
export function canEditInboundSampleSpecialMark(row: QcWorkOrder): boolean {
|
||||
const source = (row.receptionSource || '').trim()
|
||||
if (source !== 'cell_storage' && source !== 'research_donation') return false
|
||||
return !!(row.protocolSampleId || '').trim()
|
||||
}
|
||||
|
||||
export function resolveInboundSampleSpecialMarkTarget(
|
||||
workOrders: QcWorkOrder[]
|
||||
): InboundSampleSpecialMarkTarget | null {
|
||||
const gate = resolveGateWorkOrderForSampleGroup(workOrders)
|
||||
if (!gate || !canEditInboundSampleSpecialMark(gate)) return null
|
||||
const source = (gate.receptionSource || 'cell_storage').trim() as
|
||||
| 'cell_storage'
|
||||
| 'research_donation'
|
||||
return {
|
||||
receptionSource: source,
|
||||
protocolSampleId: String(gate.protocolSampleId || '').trim(),
|
||||
sampleCode: String(gate.sampleCode || '').trim(),
|
||||
isSpecial: !!gate.isSpecial,
|
||||
specialRemark: String(gate.specialRemark || '').trim()
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveInboundSampleSpecialMarkTargetFromRow(
|
||||
row: QcWorkOrder
|
||||
): InboundSampleSpecialMarkTarget | null {
|
||||
if (!canEditInboundSampleSpecialMark(row)) return null
|
||||
const source = (row.receptionSource || 'cell_storage').trim() as
|
||||
| 'cell_storage'
|
||||
| 'research_donation'
|
||||
return {
|
||||
receptionSource: source,
|
||||
protocolSampleId: String(row.protocolSampleId || '').trim(),
|
||||
sampleCode: String(row.sampleCode || '').trim(),
|
||||
isSpecial: !!row.isSpecial,
|
||||
specialRemark: String(row.specialRemark || '').trim()
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,9 @@ import { formatDisplayOrDash, formatDateOnly } from '@/utils/datetime-display'
|
||||
import type { ProductionWorkOrderItem } from '@/api/production-work-order'
|
||||
import { receptionSourceLabel } from '@/utils/qc-labels'
|
||||
import {
|
||||
donationStatusLabel,
|
||||
donationStatusDisplayLabel,
|
||||
donationStatusTagType,
|
||||
protocolStatusLabel,
|
||||
protocolStatusDisplayLabel,
|
||||
protocolStatusTagType
|
||||
} from '@/utils/status-tag-registry'
|
||||
import type { TagType } from '@/utils/status-tag-registry'
|
||||
@@ -38,8 +38,6 @@ export type ProductionWorkOrderCardMetaConfig = {
|
||||
allowDelete: boolean
|
||||
}
|
||||
|
||||
const RECEPTION_OVER_HOURS_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
function formatOutboundScheduleSortNo(row: ProductionWorkOrderItem) {
|
||||
const n = row.sortNo
|
||||
if (n == null || n === '' || Number(n) <= 0) return '—'
|
||||
@@ -52,34 +50,14 @@ function formatOutboundScheduleTotalCount(row: ProductionWorkOrderItem) {
|
||||
return `共${Number(n)}次`
|
||||
}
|
||||
|
||||
function parseReceptionTime(value?: string | null): Date | null {
|
||||
const s = String(value || '').trim()
|
||||
if (!s) return null
|
||||
const dt = new Date(s.replace(/-/g, '/'))
|
||||
return Number.isNaN(dt.getTime()) ? null : dt
|
||||
}
|
||||
|
||||
function receptionOver24HoursLabel(receptionTime?: string | null): string {
|
||||
const dt = parseReceptionTime(receptionTime)
|
||||
if (!dt) return '—'
|
||||
if (Date.now() - dt.getTime() > RECEPTION_OVER_HOURS_MS) return '是'
|
||||
return '否'
|
||||
}
|
||||
|
||||
function receptionOver24HoursTagType(receptionTime?: string | null): TagType {
|
||||
const dt = parseReceptionTime(receptionTime)
|
||||
if (!dt) return 'info'
|
||||
if (Date.now() - dt.getTime() > RECEPTION_OVER_HOURS_MS) return 'danger'
|
||||
return 'success'
|
||||
}
|
||||
|
||||
function protocolStatusLabelForTab(
|
||||
row: ProductionWorkOrderItem,
|
||||
tabKey: ProductionWorkOrderTabKey
|
||||
): string {
|
||||
const status = String(row.protocolStatus || '')
|
||||
if (tabKey === 'inbound_research_donation') return donationStatusLabel(status)
|
||||
return protocolStatusLabel(status)
|
||||
const voidType = String(row.voidType || '')
|
||||
if (tabKey === 'inbound_research_donation') return donationStatusDisplayLabel(status, voidType)
|
||||
return protocolStatusDisplayLabel(status, voidType)
|
||||
}
|
||||
|
||||
function protocolStatusTagTypeForTab(
|
||||
@@ -113,13 +91,6 @@ function buildInboundMetaFields(tabKey: ProductionWorkOrderTabKey): CardMetaFiel
|
||||
},
|
||||
{ label: '样本类型', getValue: (row) => row.sampleTypeName || '—' },
|
||||
{ label: '接收经手人', getValue: (row) => row.handlerName || '—' },
|
||||
{
|
||||
label: '是否超24小时',
|
||||
kind: 'tag',
|
||||
getValue: (row) => receptionOver24HoursLabel(row.receptionTime),
|
||||
getTagType: (row) => receptionOver24HoursTagType(row.receptionTime),
|
||||
visible: (row) => !!row.receptionTime
|
||||
},
|
||||
{ label: '接收时间', getValue: (row) => formatDisplayOrDash(row.receptionTime) },
|
||||
{ label: '创建时间', getValue: (row) => formatDisplayOrDash(row.createTime) },
|
||||
{ label: '工单号', getValue: (row) => row.workOrderNo || '—' },
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { ProductionWorkOrderItem } from '@/api/production-work-order'
|
||||
import type { ProductionWorkOrderTabStepCount } from '@/api/production-work-order'
|
||||
import { isProductionWorkOrderTabStepBadgeZero } from '@/api/production-work-order'
|
||||
|
||||
export type ProductionWorkOrderStepBadgeScope = 'manage' | 'mine'
|
||||
|
||||
const ACTIVE_WORK_ORDER_STATUSES = new Set(['dpz', 'jxz'])
|
||||
const ACTIVE_TASK_STATUSES = new Set(['dpz', 'jxz'])
|
||||
|
||||
function todayYmd(): string {
|
||||
const d = new 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}`
|
||||
}
|
||||
|
||||
function isActiveWorkOrder(row: Pick<ProductionWorkOrderItem, 'workOrderStatus'>): boolean {
|
||||
return ACTIVE_WORK_ORDER_STATUSES.has((row.workOrderStatus || '').trim())
|
||||
}
|
||||
|
||||
function isActiveTask(task: Record<string, any>): boolean {
|
||||
return ACTIVE_TASK_STATUSES.has((task.taskStatus || '').trim())
|
||||
}
|
||||
|
||||
function isActiveStep(step: Record<string, any>): boolean {
|
||||
return (step.stepStatus || '').trim() !== 'ywc'
|
||||
}
|
||||
|
||||
function stepAssigneeUserIds(step: Record<string, any>): string[] {
|
||||
const ids = step.assigneeUserIds
|
||||
if (Array.isArray(ids) && ids.length) {
|
||||
return ids.map((id) => String(id || '').trim()).filter(Boolean)
|
||||
}
|
||||
const assignees = step.assignees
|
||||
if (!Array.isArray(assignees)) return []
|
||||
return assignees
|
||||
.map((a) => String(a?.userId || a?.id || '').trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function isStepUnassigned(step: Record<string, any>): boolean {
|
||||
return stepAssigneeUserIds(step).length === 0
|
||||
}
|
||||
|
||||
/** 与后端 tab-step-counts 一致:计划开始日期已到期且步骤未完成 */
|
||||
function isStepOverdueIncomplete(step: Record<string, any>): boolean {
|
||||
const planned = (step.plannedStartTime || '').trim().slice(0, 10)
|
||||
if (!planned) return false
|
||||
return planned <= todayYmd()
|
||||
}
|
||||
|
||||
/** 与后端 mine-tab-step-counts 任务过滤一致 */
|
||||
function isMineQualifyingTask(task: Record<string, any>, userId: string): boolean {
|
||||
const uid = userId.trim()
|
||||
if (!uid) return false
|
||||
if (String(task.currentAssigneeUserId || '').trim() === uid) return true
|
||||
const steps = Array.isArray(task.steps) ? task.steps : []
|
||||
for (const step of steps) {
|
||||
if (stepAssigneeUserIds(step).includes(uid)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** 单张生产工单卡片:工艺步骤到期未完成 / 未安排负责人数(与 Tab 角标语义一致) */
|
||||
export function sumProductionWorkOrderStepBadgeCount(
|
||||
workOrder: ProductionWorkOrderItem,
|
||||
tasks: Record<string, any>[] | undefined,
|
||||
scope: ProductionWorkOrderStepBadgeScope,
|
||||
currentUserId?: string
|
||||
): ProductionWorkOrderTabStepCount {
|
||||
const result: ProductionWorkOrderTabStepCount = { overdueIncomplete: 0, unassigned: 0 }
|
||||
if (!isActiveWorkOrder(workOrder)) return result
|
||||
|
||||
const uid = String(currentUserId || '').trim()
|
||||
for (const task of tasks || []) {
|
||||
if (!isActiveTask(task)) continue
|
||||
if (scope === 'mine' && uid && !isMineQualifyingTask(task, uid)) continue
|
||||
|
||||
for (const step of Array.isArray(task.steps) ? task.steps : []) {
|
||||
if (!isActiveStep(step)) continue
|
||||
if (isStepOverdueIncomplete(step)) result.overdueIncomplete += 1
|
||||
if (isStepUnassigned(step)) result.unassigned += 1
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function isProductionWorkOrderStepBadgeVisible(
|
||||
count: ProductionWorkOrderTabStepCount
|
||||
): boolean {
|
||||
return !isProductionWorkOrderTabStepBadgeZero(count)
|
||||
}
|
||||
|
||||
export function productionWorkOrderStepBadgeTitle(count: ProductionWorkOrderTabStepCount): string {
|
||||
if (isProductionWorkOrderTabStepBadgeZero(count)) return ''
|
||||
const overdue = count.overdueIncomplete || 0
|
||||
const unassigned = count.unassigned || 0
|
||||
const parts: string[] = []
|
||||
if (overdue > 0) {
|
||||
parts.push(`${overdue} 个工艺步骤已到期未完成`)
|
||||
}
|
||||
if (unassigned > 0) {
|
||||
parts.push(`${unassigned} 个工艺步骤未安排负责人`)
|
||||
}
|
||||
return parts.join(',')
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export {
|
||||
protocolStatusLabel,
|
||||
protocolStatusDisplayLabel,
|
||||
protocolStatusTagType,
|
||||
payStatusLabel,
|
||||
payStatusTagType,
|
||||
|
||||
@@ -223,6 +223,9 @@ export function buildWorkOrderFromTasks(tasks: QcTask[]): QcWorkOrder {
|
||||
cellProductPassage: t.cellProductPassage,
|
||||
cellProductSpecName: t.cellProductSpecName,
|
||||
sampleTypeName: t.sampleTypeName,
|
||||
isSpecial: tasks.some((x) => x.isSpecial) || !!t.isSpecial,
|
||||
specialRemark:
|
||||
String(tasks.find((x) => x.isSpecial)?.specialRemark || t.specialRemark || '').trim(),
|
||||
receptionTime: t.receptionTime,
|
||||
handlerName: t.handlerName,
|
||||
hasLab: t.hasLab ?? tasks.some((x) => x.taskType === 'sysz'),
|
||||
@@ -252,7 +255,8 @@ export function buildWorkOrderFromTasks(tasks: QcTask[]): QcWorkOrder {
|
||||
protocolStatusLabel: t.protocolStatusLabel,
|
||||
paymentSummary: t.paymentSummary,
|
||||
feeSummary: t.feeSummary,
|
||||
remark: t.workOrderRemark || t.remark
|
||||
remark: t.workOrderRemark || t.remark,
|
||||
unfinishedBadgeCount: t.unfinishedBadgeCount
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import JSZip from 'jszip'
|
||||
import FileSaver from 'file-saver'
|
||||
import type { QcWorkOrder } from '@/api/qc-work-order'
|
||||
import type { QcInspectionBatch } from '@/utils/qc-inspection-batch'
|
||||
import {
|
||||
ensureUniqueZipEntryName,
|
||||
fetchAttachmentBlob,
|
||||
resolveAttachmentFileName,
|
||||
sanitizeZipPathSegment
|
||||
} from '@/utils/attachment-zip'
|
||||
|
||||
export type AttachmentZipExportPhase =
|
||||
| 'preparing'
|
||||
| 'downloading'
|
||||
| 'zipping'
|
||||
| 'done'
|
||||
| 'cancelled'
|
||||
|
||||
export type AttachmentZipExportProgress = {
|
||||
phase: AttachmentZipExportPhase
|
||||
percent: number
|
||||
currentFile?: string
|
||||
currentIndex?: number
|
||||
totalFiles?: number
|
||||
contextHint?: string
|
||||
}
|
||||
|
||||
export type AttachmentZipExportEntry = {
|
||||
zipPath: string
|
||||
fileUrl: string
|
||||
fileName: string
|
||||
contextHint?: string
|
||||
}
|
||||
|
||||
export type AttachmentZipExportResult = {
|
||||
added: number
|
||||
failed: number
|
||||
cancelled: boolean
|
||||
}
|
||||
|
||||
const PREPARING_MAX = 5
|
||||
const DOWNLOADING_MAX = 85
|
||||
|
||||
function workOrderFolderName(row: QcWorkOrder, used: Set<string>) {
|
||||
const parts = [sanitizeZipPathSegment(row.workOrderNo || row.id, '工单')]
|
||||
if (row.sampleCode) {
|
||||
parts.push(`样本${sanitizeZipPathSegment(row.sampleCode)}`)
|
||||
}
|
||||
return ensureUniqueZipEntryName(parts.join('_'), used)
|
||||
}
|
||||
|
||||
function batchFolderName(batch: QcInspectionBatch, used: Set<string>) {
|
||||
const no = sanitizeZipPathSegment(batch.batchNo || '', '')
|
||||
const name = sanitizeZipPathSegment(batch.batchName || '未命名批次')
|
||||
const base = no ? `${no}_${name}` : name || `批次${batch.orderNum ?? 1}`
|
||||
return ensureUniqueZipEntryName(base, used)
|
||||
}
|
||||
|
||||
function taskFolderName(task: { taskNo?: string; itemName?: string }, used: Set<string>) {
|
||||
const no = sanitizeZipPathSegment(task.taskNo || '', '')
|
||||
const name = sanitizeZipPathSegment(task.itemName || '未命名任务')
|
||||
const base = no ? `${no}_${name}` : name
|
||||
return ensureUniqueZipEntryName(base, used)
|
||||
}
|
||||
|
||||
export function buildQcWorkOrderZipEntries(
|
||||
workOrders: QcWorkOrder[],
|
||||
batchMap: Record<string, QcInspectionBatch[]>
|
||||
): AttachmentZipExportEntry[] {
|
||||
const entries: AttachmentZipExportEntry[] = []
|
||||
const usedWorkOrderFolders = new Set<string>()
|
||||
|
||||
for (const wo of workOrders) {
|
||||
const woFolder = workOrderFolderName(wo, usedWorkOrderFolders)
|
||||
const batches = batchMap[wo.id] || []
|
||||
const usedBatchFolders = new Set<string>()
|
||||
|
||||
for (const batch of batches) {
|
||||
const batchFolder = batchFolderName(batch, usedBatchFolders)
|
||||
const tasks = batch.tasks || []
|
||||
const usedTaskFolders = new Set<string>()
|
||||
|
||||
for (const task of tasks) {
|
||||
const files = task.attachmentFiles?.filter((f) => String(f.fileUrl || '').trim()) || []
|
||||
if (!files.length) continue
|
||||
|
||||
const taskFolder = taskFolderName(task, usedTaskFolders)
|
||||
const usedFileNames = new Set<string>()
|
||||
const contextHint = [wo.workOrderNo, batch.batchName, task.itemName]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
|
||||
files.forEach((file, fileIndex) => {
|
||||
const fileName = resolveAttachmentFileName(file, fileIndex)
|
||||
const uniqueName = ensureUniqueZipEntryName(fileName, usedFileNames)
|
||||
entries.push({
|
||||
zipPath: `${woFolder}/${batchFolder}/${taskFolder}/${uniqueName}`,
|
||||
fileUrl: file.fileUrl,
|
||||
fileName,
|
||||
contextHint
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
function calcDownloadingPercent(currentIndex: number, totalFiles: number) {
|
||||
if (totalFiles <= 0) return PREPARING_MAX
|
||||
const ratio = Math.min(1, (currentIndex + 1) / totalFiles)
|
||||
return PREPARING_MAX + ratio * (DOWNLOADING_MAX - PREPARING_MAX)
|
||||
}
|
||||
|
||||
function calcZippingPercent(zipPercent: number) {
|
||||
return DOWNLOADING_MAX + (zipPercent / 100) * (100 - DOWNLOADING_MAX)
|
||||
}
|
||||
|
||||
export async function runAttachmentZipExport(options: {
|
||||
entries: AttachmentZipExportEntry[]
|
||||
zipFileName: string
|
||||
signal?: AbortSignal
|
||||
onProgress?: (progress: AttachmentZipExportProgress) => void
|
||||
}): Promise<AttachmentZipExportResult> {
|
||||
const { entries, zipFileName, signal, onProgress } = options
|
||||
const zip = new JSZip()
|
||||
let added = 0
|
||||
let failed = 0
|
||||
|
||||
const report = (partial: AttachmentZipExportProgress) => {
|
||||
onProgress?.(partial)
|
||||
}
|
||||
|
||||
const totalFiles = entries.length
|
||||
for (let i = 0; i < entries.length; i += 1) {
|
||||
if (signal?.aborted) {
|
||||
report({ phase: 'cancelled', percent: calcDownloadingPercent(i, totalFiles) })
|
||||
return { added, failed, cancelled: true }
|
||||
}
|
||||
|
||||
const entry = entries[i]
|
||||
report({
|
||||
phase: 'downloading',
|
||||
percent: Math.round(calcDownloadingPercent(i, totalFiles)),
|
||||
currentFile: entry.fileName,
|
||||
currentIndex: i + 1,
|
||||
totalFiles,
|
||||
contextHint: entry.contextHint
|
||||
})
|
||||
|
||||
try {
|
||||
const blob = await fetchAttachmentBlob(entry.fileUrl, entry.fileName, { signal })
|
||||
if (signal?.aborted) {
|
||||
report({ phase: 'cancelled', percent: calcDownloadingPercent(i, totalFiles) })
|
||||
return { added, failed, cancelled: true }
|
||||
}
|
||||
if (blob) {
|
||||
zip.file(entry.zipPath, blob)
|
||||
added += 1
|
||||
} else {
|
||||
failed += 1
|
||||
}
|
||||
} catch {
|
||||
if (signal?.aborted) {
|
||||
report({ phase: 'cancelled', percent: calcDownloadingPercent(i, totalFiles) })
|
||||
return { added, failed, cancelled: true }
|
||||
}
|
||||
failed += 1
|
||||
}
|
||||
}
|
||||
|
||||
if (added === 0) {
|
||||
report({ phase: 'done', percent: 100 })
|
||||
return { added, failed, cancelled: false }
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
return { added, failed, cancelled: true }
|
||||
}
|
||||
|
||||
report({
|
||||
phase: 'zipping',
|
||||
percent: DOWNLOADING_MAX,
|
||||
totalFiles,
|
||||
currentIndex: totalFiles
|
||||
})
|
||||
|
||||
const content = await zip.generateAsync({ type: 'blob' }, (metadata) => {
|
||||
if (signal?.aborted) return
|
||||
report({
|
||||
phase: 'zipping',
|
||||
percent: Math.round(calcZippingPercent(metadata.percent)),
|
||||
totalFiles,
|
||||
currentIndex: totalFiles
|
||||
})
|
||||
})
|
||||
|
||||
if (signal?.aborted) {
|
||||
return { added, failed, cancelled: true }
|
||||
}
|
||||
|
||||
FileSaver.saveAs(content, zipFileName)
|
||||
report({ phase: 'done', percent: 100, totalFiles, currentIndex: totalFiles })
|
||||
return { added, failed, cancelled: false }
|
||||
}
|
||||
|
||||
export function reportPreparingProgress(
|
||||
onProgress?: (progress: AttachmentZipExportProgress) => void
|
||||
) {
|
||||
onProgress?.({ phase: 'preparing', percent: 2 })
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { formatDisplayOrDash, formatDateOnly } from '@/utils/datetime-display'
|
||||
import type { QcWorkOrder, QcProtocolPaymentSummary } from '@/api/qc-work-order'
|
||||
import {
|
||||
donationStatusLabel,
|
||||
donationStatusDisplayLabel,
|
||||
donationStatusTagType,
|
||||
protocolStatusLabel,
|
||||
protocolStatusDisplayLabel,
|
||||
protocolStatusTagType
|
||||
} from '@/utils/status-tag-registry'
|
||||
import type { TagType } from '@/utils/status-tag-registry'
|
||||
@@ -83,8 +83,10 @@ function isInboundUserInfoRow(row: QcWorkOrder) {
|
||||
return source === 'cell_storage' || source === 'research_donation'
|
||||
}
|
||||
|
||||
function protocolStatusLabelBySource(status: string, source: string): string {
|
||||
return source === 'research_donation' ? donationStatusLabel(status) : protocolStatusLabel(status)
|
||||
function protocolStatusLabelBySource(status: string, source: string, voidType?: string): string {
|
||||
return source === 'research_donation'
|
||||
? donationStatusDisplayLabel(status, voidType)
|
||||
: protocolStatusDisplayLabel(status, voidType)
|
||||
}
|
||||
|
||||
function protocolStatusTagTypeBySource(status: string, source: string): TagType {
|
||||
@@ -104,9 +106,27 @@ const commonTailFields: CardMetaField[] = [
|
||||
}
|
||||
]
|
||||
|
||||
const inboundProtocolStatusField: CardMetaField = {
|
||||
label: '协议状态',
|
||||
kind: 'tag',
|
||||
getValue: (row) =>
|
||||
protocolStatusLabelBySource(
|
||||
String(row.protocolStatus || ''),
|
||||
row.receptionSource || 'cell_storage',
|
||||
String(row.voidType || '')
|
||||
),
|
||||
getTagType: (row) =>
|
||||
protocolStatusTagTypeBySource(
|
||||
String(row.protocolStatus || ''),
|
||||
row.receptionSource || 'cell_storage'
|
||||
),
|
||||
visible: isInboundUserInfoRow
|
||||
}
|
||||
|
||||
const inboundGroupedFields: CardMetaField[] = [
|
||||
{ label: '母亲姓名', getValue: (row) => row.motherName || '—', visible: isInboundUserInfoRow },
|
||||
{ label: '新生儿姓名', getValue: (row) => row.newbornName || '—', visible: isInboundUserInfoRow },
|
||||
inboundProtocolStatusField,
|
||||
{ label: '质检对象', getValue: formatQcTarget, colSpan: 2 },
|
||||
{
|
||||
label: '关联生产工单',
|
||||
@@ -170,10 +190,6 @@ function buildCellStorageGroupContext(row: QcWorkOrder): string[] {
|
||||
if (row.motherName) parts.push(`母亲 ${row.motherName}`)
|
||||
if (row.newbornName) parts.push(`新生儿 ${row.newbornName}`)
|
||||
if (row.sampleTypeName) parts.push(row.sampleTypeName)
|
||||
if (row.protocolNo) parts.push(`协议 ${row.protocolNo}`)
|
||||
if (row.protocolStatus) {
|
||||
parts.push(protocolStatusLabelBySource(String(row.protocolStatus), 'cell_storage'))
|
||||
}
|
||||
if (row.receptionTime) parts.push(`接收 ${row.receptionTime}`)
|
||||
if (row.handlerName) parts.push(row.handlerName)
|
||||
return parts
|
||||
@@ -184,10 +200,6 @@ function buildDonationGroupContext(row: QcWorkOrder): string[] {
|
||||
if (row.motherName) parts.push(`母亲 ${row.motherName}`)
|
||||
if (row.newbornName) parts.push(`新生儿 ${row.newbornName}`)
|
||||
if (row.sampleTypeName) parts.push(row.sampleTypeName)
|
||||
if (row.protocolNo) parts.push(`捐赠 ${row.protocolNo}`)
|
||||
if (row.protocolStatus) {
|
||||
parts.push(donationStatusLabel(String(row.protocolStatus)))
|
||||
}
|
||||
if (row.receptionTime) parts.push(`接收 ${row.receptionTime}`)
|
||||
if (row.handlerName) parts.push(row.handlerName)
|
||||
return parts
|
||||
@@ -204,10 +216,13 @@ export function resolveProtocolGroupStatus(row: QcWorkOrder): {
|
||||
label: string
|
||||
tagType: TagType
|
||||
} {
|
||||
const status = String(row.protocolStatus || '')
|
||||
const status = String(row.protocolStatus || '').trim()
|
||||
const source = row.receptionSource || 'cell_storage'
|
||||
if (!status) {
|
||||
return { label: '—', tagType: 'info' }
|
||||
}
|
||||
return {
|
||||
label: protocolStatusLabelBySource(status, source),
|
||||
label: protocolStatusLabelBySource(status, source, String(row.voidType || '')),
|
||||
tagType: protocolStatusTagTypeBySource(status, source)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { QcWorkOrder } from '@/api/qc-work-order'
|
||||
|
||||
/** 与 Tab 角标、后端 _unfinished_work_order_filter 一致:待配置 + 已配置 */
|
||||
export function isUnfinishedQcWorkOrder(row: Pick<QcWorkOrder, 'workOrderStatus'>): boolean {
|
||||
const status = (row.workOrderStatus || '').trim()
|
||||
return status === 'dpz' || status === 'ypz'
|
||||
}
|
||||
|
||||
/** 单张工单卡片:未完成则计 1,否则 0 */
|
||||
export function resolveQcWorkOrderUnfinishedBadgeCount(row: QcWorkOrder): number {
|
||||
return isUnfinishedQcWorkOrder(row) ? 1 : 0
|
||||
}
|
||||
|
||||
/** 分组标题栏:组内未完成工单条数(同一样本多条质检等场景) */
|
||||
export function sumQcWorkOrderGroupUnfinishedBadgeCount(workOrders: QcWorkOrder[]): number {
|
||||
if (!workOrders.length) return 0
|
||||
return workOrders.filter(isUnfinishedQcWorkOrder).length
|
||||
}
|
||||
|
||||
export function formatQcWorkOrderUnfinishedBadgeCount(count: number): string {
|
||||
return count > 99 ? '99+' : String(count)
|
||||
}
|
||||
|
||||
export function qcWorkOrderUnfinishedBadgeTitle(count: number): string {
|
||||
if (count <= 0) return ''
|
||||
return count === 1 ? '1 条未完成工单' : `${count} 条未完成工单`
|
||||
}
|
||||
@@ -1,15 +1,9 @@
|
||||
/**
|
||||
* 状态标签注册表 — 全局集中定义所有枚举字段的 label + tagType
|
||||
*
|
||||
* 使用方式:
|
||||
* import { protocolStatusLabel, protocolStatusTagType } from '@/utils/status-tag-registry'
|
||||
* <el-tag :type="protocolStatusTagType(row.status)">{{ protocolStatusLabel(row.status) }}</el-tag>
|
||||
*
|
||||
* 规范:同一枚举值在任何地方(表格、查看弹窗、导出)颜色必须一致。
|
||||
* tagType 语义色:success=正常/已完成, warning=进行中/已过期, danger=异常/已作废,
|
||||
* info=待执行/停用, primary=特殊分类/转出, ''=默认
|
||||
*/
|
||||
|
||||
import { resolveVoidTypeLabel } from '@/utils/void-type-dict'
|
||||
|
||||
// ── 类型定义 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** el-tag 的 type 属性值 */
|
||||
@@ -43,21 +37,50 @@ export const protocolStatus: StatusRegistry = {
|
||||
sxz: { label: '生效中', tagType: 'primary' },
|
||||
ygq: { label: '已过期', tagType: 'warning' },
|
||||
yzf: { label: '已作废', tagType: 'danger' },
|
||||
sdfq: { label: '属地废弃', tagType: 'danger' },
|
||||
zky: { label: '转科研', tagType: 'primary' }
|
||||
}
|
||||
export const protocolStatusLabel = createLabelGetter(protocolStatus)
|
||||
export const protocolStatusTagType = createTagTypeGetter(protocolStatus)
|
||||
|
||||
/** 协议状态展示文案;已作废时追加作废类型,如「已作废(客户毁存)」 */
|
||||
export function protocolStatusDisplayLabel(status: string, voidType?: string): string {
|
||||
const base = protocolStatusLabel(status)
|
||||
if ((status || '').trim() !== 'yzf') return base
|
||||
const vt = resolveVoidTypeLabel(voidType || '')
|
||||
return vt ? `${base}(${vt})` : base
|
||||
}
|
||||
|
||||
export const donationStatus: StatusRegistry = {
|
||||
dsx: { label: '待生效', tagType: 'info' },
|
||||
sxz: { label: '生效中', tagType: 'primary' },
|
||||
ygq: { label: '已过期', tagType: 'warning' },
|
||||
yzf: { label: '已作废', tagType: 'danger' },
|
||||
sdfq: { label: '属地废弃', tagType: 'danger' },
|
||||
zckc: { label: '转细胞存储', tagType: 'primary' }
|
||||
}
|
||||
export const donationStatusLabel = createLabelGetter(donationStatus)
|
||||
export const donationStatusTagType = createTagTypeGetter(donationStatus)
|
||||
|
||||
/** 捐赠协议状态展示文案;已作废时追加作废类型 */
|
||||
export function donationStatusDisplayLabel(status: string, voidType?: string): string {
|
||||
const base = donationStatusLabel(status)
|
||||
if ((status || '').trim() !== 'yzf') return base
|
||||
const vt = resolveVoidTypeLabel(voidType || '')
|
||||
return vt ? `${base}(${vt})` : base
|
||||
}
|
||||
|
||||
/** 按来源(细胞存储 / 科研捐赠)展示协议状态,已作废时带作废类型 */
|
||||
export function protocolStatusDisplayLabelBySource(
|
||||
status: string,
|
||||
source: string,
|
||||
voidType?: string
|
||||
): string {
|
||||
return source === 'research_donation'
|
||||
? donationStatusDisplayLabel(status, voidType)
|
||||
: protocolStatusDisplayLabel(status, voidType)
|
||||
}
|
||||
|
||||
export const sampleStatus: StatusRegistry = {
|
||||
ycj: { label: '已采集', tagType: 'primary' },
|
||||
jcz: { label: '检测中', tagType: 'warning' },
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { shallowRef } from 'vue'
|
||||
import { fetchDictDataOptions } from '@/api/admin'
|
||||
|
||||
/** 协议作废类型字典编码(与 sys_dict_type.dict_type 一致) */
|
||||
export const VOID_TYPE_DICT = 'xyzflx'
|
||||
|
||||
const FALLBACK_LABELS: Record<string, string> = {
|
||||
khhc: '客户毁存',
|
||||
jcbhg: '检测不合格'
|
||||
}
|
||||
|
||||
function buildFallbackOptions(): Api.Dict.DictDataOption[] {
|
||||
return Object.entries(FALLBACK_LABELS).map(([value, label]) => ({ value, label }))
|
||||
}
|
||||
|
||||
let labelMap: Record<string, string> = { ...FALLBACK_LABELS }
|
||||
let dictLoaded = false
|
||||
let loadPromise: Promise<void> | null = null
|
||||
|
||||
/** 作废类型下拉选项(响应式,供弹窗 v-for 绑定) */
|
||||
export const voidTypeOptionsRef = shallowRef<Api.Dict.DictDataOption[]>(buildFallbackOptions())
|
||||
|
||||
export function getVoidTypeOptions(): Api.Dict.DictDataOption[] {
|
||||
return voidTypeOptionsRef.value
|
||||
}
|
||||
|
||||
export async function loadVoidTypeDict(force = false): Promise<void> {
|
||||
if (!force && dictLoaded) return
|
||||
if (!force && loadPromise) return loadPromise
|
||||
loadPromise = (async () => {
|
||||
try {
|
||||
const res = await fetchDictDataOptions(VOID_TYPE_DICT)
|
||||
voidTypeOptionsRef.value = res?.length ? res : buildFallbackOptions()
|
||||
labelMap = {
|
||||
...FALLBACK_LABELS,
|
||||
...Object.fromEntries(voidTypeOptionsRef.value.map((o) => [o.value, o.label]))
|
||||
}
|
||||
dictLoaded = true
|
||||
} catch (e) {
|
||||
console.error('加载作废类型字典失败:', e)
|
||||
voidTypeOptionsRef.value = buildFallbackOptions()
|
||||
labelMap = { ...FALLBACK_LABELS }
|
||||
} finally {
|
||||
loadPromise = null
|
||||
}
|
||||
})()
|
||||
return loadPromise
|
||||
}
|
||||
|
||||
export function resolveVoidTypeLabel(value: string): string {
|
||||
const code = String(value || '').trim()
|
||||
if (!code) return ''
|
||||
return labelMap[code] || FALLBACK_LABELS[code] || code
|
||||
}
|
||||
|
||||
export const voidTypeLabel = resolveVoidTypeLabel
|
||||
Reference in new issue
Block a user