This commit is contained in:
zlwu committed 2026-07-01 16:37:33 +08:00
1 parent 28f233c922
commit 038a60b09e
478 files changed
+67530 -51951

No files matched your search

+94
View File
@@ -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,
+5 -1
View File
@@ -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 })
}
+29 -14
View File
@@ -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} 条未完成工单`
}
+31 -8
View File
@@ -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' },
+56
View File
@@ -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