fix: 协议子资源单事务保存,并补齐样本编码/OA/质检等近期能力

锁定缴费校验失败时整单回滚,避免样本半成功落库;前端禁止删除锁定缴费样本,改码走更正编码。同步落地样本更正、采集微信通知、OA提及未读、细胞产品套餐与相关迁移文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-10-26 11:02:25 +08:00
1 parent 19bc66ba45
commit 5dad1bb0e3
142 files changed
+95584 -65717

No files matched your search

+27
View File
@@ -1675,6 +1675,33 @@ export function fetchDeleteCellProductSpec(id: string) {
return request.del({ url: `/web/admin/cell-product-specs/${id}` })
}
// ---------------------------------------------------------------------------
// 中试服务管理 — 细胞产品包装
// ---------------------------------------------------------------------------
export function fetchCellProductPackageList(params?: {
page?: number
pageSize?: number
keyword?: string
}) {
return request.get({
url: '/web/admin/cell-product-packages',
params
})
}
export function fetchCreateCellProductPackage(data: Record<string, any>) {
return request.post({ url: '/web/admin/cell-product-packages', params: data })
}
export function fetchUpdateCellProductPackage(id: string, data: Record<string, any>) {
return request.put({ url: `/web/admin/cell-product-packages/${id}`, params: data })
}
export function fetchDeleteCellProductPackage(id: string) {
return request.del({ url: `/web/admin/cell-product-packages/${id}` })
}
// ---------------------------------------------------------------------------
// 中试服务管理 — 细胞产品价格
// ---------------------------------------------------------------------------
+11
View File
@@ -488,6 +488,17 @@ export function fetchOaCcUnreadCount() {
return request.get<{ count: number }>({ url: `${BASE}/tasks/cc/unread-count` })
}
export function fetchOaMentionUnreadCount() {
return request.get<{ count: number }>({ url: `${BASE}/mentions/unread-count` })
}
export function fetchMarkOaMentionRead(instanceId: string) {
return request.post({
url: `${BASE}/mentions/${instanceId}/read`,
showSuccessMessage: false
})
}
export function fetchMarkOaCcTaskRead(taskId: string) {
return request.post({
url: `${BASE}/tasks/${taskId}/read`,
+2
View File
@@ -234,6 +234,8 @@ export function fetchQcWorkOrderList(params?: {
pageSize?: number
keyword?: string
workOrderStatus?: string
/** 协议/捐赠状态,逗号分隔;科研捐赠 Tab 使用;空串表示无匹配 */
protocolStatus?: string
workOrderType?: string
bizType?: string
receptionSource?: string
@@ -0,0 +1,63 @@
import request from '@/utils/http'
export type SampleCodeCorrectionSource = 'cell_storage' | 'research_donation'
export interface SampleCodeCorrectionImpact {
table: string
label: string
count: number
}
export interface SampleCodeCorrectionPreview {
ok: boolean
blockers: string[]
receptionSource: SampleCodeCorrectionSource
oldSampleCode: string
newSampleCode: string
sampleId?: string | null
impacts: SampleCodeCorrectionImpact[]
totalAffectedRows: number
hints?: {
sameProductCodeAsOld?: number
sameProductCodeAsNew?: number
labStorageCodeUnchanged?: boolean
cellProductCodeNotAutoUpdated?: boolean
}
}
export interface SampleCodeCorrectionApplyResult {
ok: boolean
logId: string
receptionSource: SampleCodeCorrectionSource
oldSampleCode: string
newSampleCode: string
impactCounts: Record<string, number>
totalAffectedRows: number
hints?: SampleCodeCorrectionPreview['hints']
}
export function fetchPreviewSampleCodeCorrection(data: {
receptionSource: SampleCodeCorrectionSource
oldSampleCode: string
newSampleCode: string
newSampleCodeId?: string | null
reason?: string
}) {
return request.post<SampleCodeCorrectionPreview>({
url: '/web/admin/sample-code-corrections/preview',
data
})
}
export function fetchApplySampleCodeCorrection(data: {
receptionSource: SampleCodeCorrectionSource
oldSampleCode: string
newSampleCode: string
newSampleCodeId?: string | null
reason?: string
}) {
return request.post<SampleCodeCorrectionApplyResult>({
url: '/web/admin/sample-code-corrections/apply',
data
})
}
@@ -166,12 +166,18 @@
/>
</el-select>
</el-form-item>
<el-form-item label="样本编码">
<el-form-item>
<template #label>
样本编码
<span v-if="isPersistedSampleCodeLocked" class="form-label-tip"
>已落库不可改,请用客户服务「更正编码」</span
>
</template>
<el-input
v-if="autoGenerateSampleCode"
v-if="autoGenerateSampleCode || isPersistedSampleCodeLocked"
:model-value="sampleDraft.sampleCode"
disabled
placeholder="系统自动生成"
:placeholder="autoGenerateSampleCode ? '系统自动生成' : '—'"
/>
<el-select
v-else
@@ -381,6 +387,7 @@
} from '@/components/business/freight-unlink-dialog/index.vue'
import { sampleStatusLabel, sampleStatusTagType } from '@/utils/status-tag-registry'
import { formatMoneyDisplay } from '@/utils/money-format'
import { samplePaymentHasLockedTransfer } from '@/utils/protocol-sample-payment'
import {
buildFreightHeaderPayload,
freightAmountText as formatFreightAmountText,
@@ -424,6 +431,8 @@
siteOptions?: Api.Site.SiteItem[]
expressCompanyOptions?: Api.ExpressCompany.ExpressCompanyItem[]
showPrice?: boolean
/** 按样本缴费方案(用于判断锁定转账,禁止删除对应样本) */
samplePayments?: Array<Record<string, any>>
/** 科研捐赠等:列表与样本弹窗均不展示存储年限及相关采集明细 */
hideCollectDetailFields?: boolean
/** 添加/编辑样本弹窗内隐藏采集量、单位(不影响列表列) */
@@ -440,6 +449,7 @@
{
protocolId: '',
showPrice: false,
samplePayments: () => [],
hideCollectDetailFields: false,
hideDialogCollectAmountUnit: false,
autoGenerateSampleCode: false,
@@ -495,6 +505,12 @@
const canUnlinkFreight = computed(() => selectedSamples.value.some((r) => sampleHasFreight(r)))
/** 已落库样本(有持久化 id)编辑时锁定编码,纠错走客户服务「更正编码」 */
const isPersistedSampleCodeLocked = computed(
() =>
sampleDialogMode.value === 'edit' && Boolean(String(sampleDraft.id || '').trim())
)
function fmtMoney(v: unknown): string {
if (v == null || v === '') return '—'
return '¥' + formatMoneyDisplay(v)
@@ -738,7 +754,7 @@
}
row.storageYears = st.storageYears
}
if (props.autoGenerateSampleCode) return
if (props.autoGenerateSampleCode || isPersistedSampleCodeLocked.value) return
row.sampleCodeId = ''
row.sampleCode = ''
emit('search-sample-code', '', sampleCodeFilterForRow(row))
@@ -1061,6 +1077,7 @@
props.samples.length
)
)
sampleDraft.id = ''
if (props.autoGenerateSampleCode && props.reserveSampleCode) {
try {
sampleDraft.sampleCode = await props.reserveSampleCode()
@@ -1137,7 +1154,27 @@
sampleDialogVisible.value = false
}
function sampleHasLockedPayment(row: Record<string, any>): boolean {
const code = String(row?.sampleCode || '').trim()
const sampleId = String(row?.id || row?.protocolSampleId || '').trim()
return (props.samplePayments || []).some((pay) => {
if (!samplePaymentHasLockedTransfer(pay)) return false
const payCode = String(pay.sampleCode || '').trim()
const paySampleId = String(pay.protocolSampleId || '').trim()
if (code && payCode && code === payCode) return true
if (sampleId && paySampleId && sampleId === paySampleId) return true
return false
})
}
function removeSample(index: number) {
const row = props.samples[index]
if (row && sampleHasLockedPayment(row)) {
ElMessage.warning(
'该样本存在锁定缴费方案,不可删除;请使用客户服务「更正编码」调整样本编码'
)
return
}
const next = [...props.samples]
next.splice(index, 1)
updateSamples(next)
@@ -17,8 +17,8 @@
<div class="val">{{ identity.labFlowCode || '—' }}</div>
</div>
<div class="ctx-item">
<label>协议编码</label>
<div class="val">{{ identity.protocolCode || '—' }}</div>
<label>样本编码</label>
<div class="val">{{ identity.sampleCode || '—' }}</div>
</div>
<div class="ctx-item">
<label>母亲姓名</label>
@@ -32,7 +32,7 @@
<el-empty
v-if="mode === 'view' && !filledFlag && !loading"
description="质检尚未填写"
description="尚未填写"
/>
<template v-else-if="typeKey && groups.length">
@@ -147,7 +147,7 @@
const form = reactive<Record<string, string>>({})
const identity = reactive({
labFlowCode: '',
protocolCode: '',
sampleCode: '',
motherName: '',
sampleTypeName: ''
})
@@ -176,8 +176,8 @@
}
function applyDetail(detail: SampleStorageCertQcDetail) {
identity.labFlowCode = detail.labFlowCode || detail.sampleCode || ''
identity.protocolCode = detail.protocolCode || ''
identity.labFlowCode = detail.labFlowCode || ''
identity.sampleCode = detail.sampleCode || props.sampleCode || ''
identity.motherName = detail.motherName || ''
identity.sampleTypeName = detail.sampleTypeName || props.sampleTypeName || ''
filledFlag.value = !!detail.filledFlag
@@ -199,7 +199,8 @@
typeKey.value = resolveStorageCertQcTypeKey(props.sampleTypeName)
filledFlag.value = false
resetFormKeys(typeKey.value, {})
identity.labFlowCode = code
identity.labFlowCode = ''
identity.sampleCode = code
identity.sampleTypeName = props.sampleTypeName || ''
} finally {
loading.value = false
+2 -6
View File
@@ -53,9 +53,7 @@ import { ElMessage } from 'element-plus'
import { ApiStatus } from '@/utils/http/status'
import { isHttpError } from '@/utils/http/error'
import { RouteRegistry, MenuProcessor, IframeRouteManager, RoutePermissionValidator } from '../core'
import { refreshQcMenuBadges } from '@/utils/qc-menu-badge'
import { refreshProductionMenuBadges } from '@/utils/production-menu-badge'
import { refreshOaMenuBadges } from '@/utils/oa-menu-badge'
import { startMenuBadgePolling } from '@/utils/menu-badge-polling'
import { loadVoidTypeDict } from '@/utils/void-type-dict'
// 路由注册器实例
@@ -302,9 +300,7 @@ async function handleDynamicRoutes(
const menuStore = useMenuStore()
menuStore.setMenuList(menuList)
menuStore.addRemoveRouteFns(routeRegistry?.getRemoveRouteFns() || [])
void refreshQcMenuBadges()
void refreshProductionMenuBadges()
void refreshOaMenuBadges()
startMenuBadgePolling()
void loadVoidTypeDict()
// 6. 保存 iframe 路由
@@ -5,6 +5,7 @@ import { formatOaCcUnreadBadgeCount } from '@/api/oaApproval'
export const OA_MENU_CODE_CC = 'oa:cc'
export const OA_MENU_CODE_DRAFTS = 'oa:drafts'
export const OA_MENU_CODE_PENDING = 'oa:pending'
export const OA_MENU_CODE_MENTION = 'oa:mention'
export const useOaMenuBadgeStore = defineStore('oaMenuBadgeStore', () => {
const badgeByMenuCode = ref<Record<string, number>>({})
+2
View File
@@ -28,6 +28,7 @@ import { fetchLogout } from '@/api/auth'
import { RoutesAlias } from '@/router/routesAlias'
import { resolveSafeRedirectTarget } from '@/utils/router'
import { pickUserAvatarRaw } from '@/utils/user-avatar'
import { stopMenuBadgePolling } from '@/utils/menu-badge-polling'
/**
* 用户状态管理
@@ -148,6 +149,7 @@ export const useUserStore = defineStore(
lockPassword.value = ''
sessionStorage.removeItem('iframeRoutes')
useMenuStore().setHomePath('')
stopMenuBadgePolling()
useQcMenuBadgeStore().clearBadges()
useProductionMenuBadgeStore().clearBadges()
useOaMenuBadgeStore().clearBadges()
+13
View File
@@ -864,6 +864,19 @@ declare namespace Api {
updateTime: string
}
}
namespace CellProductPackage {
interface CellProductPackageItem {
id: string
packageCode: string
packageName: string
tenantNo: string
orderNum: number
remark: string
createTime: string
updateTime: string
}
}
}
declare module '*.vue' {
@@ -0,0 +1,33 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import {
DEFAULT_DONATION_PROTOCOL_STATUS_FILTER,
DONATION_PROTOCOL_STATUS_FILTER_OPTIONS,
joinProtocolStatusFilter,
matchesProtocolStatusFilter
} from './donation-protocol-status-filter'
describe('donation protocol status filter helpers', () => {
it('default filter is 待生效 + 生效中', () => {
assert.deepEqual(DEFAULT_DONATION_PROTOCOL_STATUS_FILTER, ['dsx', 'sxz'])
})
it('options cover donation statuses including void', () => {
assert.equal(DONATION_PROTOCOL_STATUS_FILTER_OPTIONS.length, 6)
assert.ok(DONATION_PROTOCOL_STATUS_FILTER_OPTIONS.some((o) => o.value === 'yzf'))
assert.ok(DONATION_PROTOCOL_STATUS_FILTER_OPTIONS.some((o) => o.value === 'sdfq'))
})
it('joinProtocolStatusFilter: empty → empty string', () => {
assert.equal(joinProtocolStatusFilter([]), '')
assert.equal(joinProtocolStatusFilter(['dsx', 'sxz']), 'dsx,sxz')
})
it('matchesProtocolStatusFilter semantics', () => {
assert.equal(matchesProtocolStatusFilter('yzf', undefined), true)
assert.equal(matchesProtocolStatusFilter('yzf', []), false)
assert.equal(matchesProtocolStatusFilter('sxz', ['dsx', 'sxz']), true)
assert.equal(matchesProtocolStatusFilter('yzf', ['dsx', 'sxz']), false)
assert.equal(matchesProtocolStatusFilter('', ['dsx']), false)
})
})
@@ -0,0 +1,39 @@
/**
* 科研捐赠质检 · 协议状态筛选(与 donationStatus / INACTIVE_PROTOCOL_STATUSES 口径一致)。
* 独立模块,便于 node:test 无依赖 API/静态资源。
*/
export const DONATION_PROTOCOL_STATUS_FILTER_OPTIONS = [
{ value: 'dsx', label: '待生效' },
{ value: 'sxz', label: '生效中' },
{ value: 'ygq', label: '已过期' },
{ value: 'yzf', label: '已作废' },
{ value: 'sdfq', label: '属地废弃' },
{ value: 'zckc', label: '转细胞存储' }
] as const
/** 默认勾选:待生效、生效中 */
export const DEFAULT_DONATION_PROTOCOL_STATUS_FILTER = ['dsx', 'sxz']
/**
* 拼协议状态查询参数。
* - 有选中:逗号串
* - 空数组:返回 ''(显式空;勿当 undefined)
*/
export function joinProtocolStatusFilter(statuses: string[]): string {
return statuses.map((s) => s.trim()).filter(Boolean).join(',')
}
/**
* 协议状态是否命中筛选。
* - filter === undefined:不限制
* - filter 为空数组:无匹配
*/
export function matchesProtocolStatusFilter(
status: string,
filter: string[] | undefined
): boolean {
if (filter === undefined) return true
if (!filter.length) return false
return filter.includes((status || '').trim())
}
@@ -0,0 +1,60 @@
import { describe, it, beforeEach, afterEach } from 'node:test'
import assert from 'node:assert/strict'
import { register } from 'node:module'
import { pathToFileURL } from 'node:url'
// 隔离 refresh* 的 Pinia/HTTP/静态资源导入链,避免 node --import tsx --test 加载失败。
const stubUrl =
'data:text/javascript,' +
[
'export async function refreshOaMenuBadges() {}',
'export async function refreshQcMenuBadges() {}',
'export async function refreshProductionMenuBadges() {}'
].join('\n')
const hookSource = `
const stubUrl = ${JSON.stringify(stubUrl)}
const names = ['oa-menu-badge', 'qc-menu-badge', 'production-menu-badge']
export async function resolve(specifier, context, nextResolve) {
const base = String(specifier).split('?')[0]
if (names.some((name) => base === './' + name || base.endsWith('/' + name))) {
return { shortCircuit: true, url: stubUrl }
}
return nextResolve(specifier, context)
}
`
register('data:text/javascript,' + encodeURIComponent(hookSource), pathToFileURL('./'))
const {
startMenuBadgePolling,
stopMenuBadgePolling,
isMenuBadgePollingActive,
MENU_BADGE_POLL_INTERVAL_MS
} = await import('./menu-badge-polling')
describe('menu-badge-polling', () => {
beforeEach(() => {
stopMenuBadgePolling()
})
afterEach(() => {
stopMenuBadgePolling()
})
it('exports 60s interval', () => {
assert.equal(MENU_BADGE_POLL_INTERVAL_MS, 60_000)
})
it('start is idempotent (single active flag)', () => {
startMenuBadgePolling()
startMenuBadgePolling()
assert.equal(isMenuBadgePollingActive(), true)
stopMenuBadgePolling()
assert.equal(isMenuBadgePollingActive(), false)
})
it('stop is safe when not started', () => {
stopMenuBadgePolling()
assert.equal(isMenuBadgePollingActive(), false)
})
})
+83
View File
@@ -0,0 +1,83 @@
import { refreshOaMenuBadges } from './oa-menu-badge'
import { refreshQcMenuBadges } from './qc-menu-badge'
import { refreshProductionMenuBadges } from './production-menu-badge'
export const MENU_BADGE_POLL_INTERVAL_MS = 60_000
let timerId: ReturnType<typeof setInterval> | null = null
let visibilityBound = false
let inFlight = false
let started = false
export function isMenuBadgePollingActive(): boolean {
return started
}
export async function refreshAllMenuBadges(): Promise<void> {
if (inFlight) return
inFlight = true
try {
await Promise.allSettled([
refreshOaMenuBadges(),
refreshQcMenuBadges(),
refreshProductionMenuBadges()
])
} finally {
inFlight = false
}
}
function onVisibilityChange(): void {
if (typeof document === 'undefined') return
if (document.hidden) {
if (timerId != null) {
clearInterval(timerId)
timerId = null
}
return
}
// 恢复可见:立即补刷,再重启 interval
void refreshAllMenuBadges()
if (started && timerId == null) {
timerId = setInterval(() => {
void refreshAllMenuBadges()
}, MENU_BADGE_POLL_INTERVAL_MS)
}
}
function bindVisibility(): void {
if (visibilityBound || typeof document === 'undefined') return
document.addEventListener('visibilitychange', onVisibilityChange)
visibilityBound = true
}
function unbindVisibility(): void {
if (!visibilityBound || typeof document === 'undefined') return
document.removeEventListener('visibilitychange', onVisibilityChange)
visibilityBound = false
}
/** 幂等:已启动则忽略。启动时立即刷一轮,再按 60s 轮询。 */
export function startMenuBadgePolling(): void {
if (started) return
started = true
bindVisibility()
void refreshAllMenuBadges()
if (typeof document !== 'undefined' && document.hidden) {
// 隐藏态不启 timer,等可见时 onVisibilityChange 重启
return
}
timerId = setInterval(() => {
void refreshAllMenuBadges()
}, MENU_BADGE_POLL_INTERVAL_MS)
}
export function stopMenuBadgePolling(): void {
started = false
if (timerId != null) {
clearInterval(timerId)
timerId = null
}
unbindVisibility()
inFlight = false
}
+17 -1
View File
@@ -1,18 +1,25 @@
import {
fetchOaCcUnreadCount,
fetchOaInstanceDraftCount,
fetchOaMentionUnreadCount,
fetchOaPendingTaskCount
} from '@/api/oaApproval'
import {
OA_MENU_CODE_CC,
OA_MENU_CODE_DRAFTS,
OA_MENU_CODE_MENTION,
OA_MENU_CODE_PENDING,
useOaMenuBadgeStore
} from '@/store/modules/oa-menu-badge'
import { useMenuStore } from '@/store/modules/menu'
import type { AppRouteRecord } from '@/types/router'
const OA_BADGE_CODES = new Set([OA_MENU_CODE_CC, OA_MENU_CODE_DRAFTS, OA_MENU_CODE_PENDING])
const OA_BADGE_CODES = new Set([
OA_MENU_CODE_CC,
OA_MENU_CODE_DRAFTS,
OA_MENU_CODE_PENDING,
OA_MENU_CODE_MENTION
])
export function resolveOaMenuBadgeCode(item: AppRouteRecord): string | undefined {
const code = (item.meta.menuCode || '').trim()
@@ -21,12 +28,14 @@ export function resolveOaMenuBadgeCode(item: AppRouteRecord): string | undefined
if (name === 'OaApprovalCc') return OA_MENU_CODE_CC
if (name === 'OaApprovalDrafts') return OA_MENU_CODE_DRAFTS
if (name === 'OaApprovalPending') return OA_MENU_CODE_PENDING
if (name === 'OaApprovalMention') return OA_MENU_CODE_MENTION
// path 兜底仅限 oa: 菜单,避免命中旧工作流 pending
if (!code.startsWith('oa:')) return undefined
const path = String(item.path || '')
if (path.endsWith('/cc') || path === 'cc') return OA_MENU_CODE_CC
if (path.endsWith('/drafts') || path === 'drafts') return OA_MENU_CODE_DRAFTS
if (path.endsWith('/pending') || path === 'pending') return OA_MENU_CODE_PENDING
if (path.endsWith('/mentions') || path === 'mentions') return OA_MENU_CODE_MENTION
return undefined
}
@@ -72,6 +81,13 @@ export async function refreshOaMenuBadges() {
.catch(() => badgeStore.setBadge(OA_MENU_CODE_PENDING, 0))
)
}
if (visibleCodes.includes(OA_MENU_CODE_MENTION)) {
jobs.push(
fetchOaMentionUnreadCount()
.then((data) => badgeStore.setBadge(OA_MENU_CODE_MENTION, data?.count ?? 0))
.catch(() => badgeStore.setBadge(OA_MENU_CODE_MENTION, 0))
)
}
await Promise.all(jobs)
}
@@ -16,6 +16,8 @@ import {
applyExpandRemainingToInstallment,
availableSamplePayModes,
samplePaymentHasConfirmedTransfer,
isSamplePaymentTransferLocked,
samplePaymentHasLockedTransfer,
calcLastPeriodDueAmount,
shouldAutoFillLastPeriodDue,
applyLumpSumDeadlineToLastDueDate
@@ -231,6 +233,30 @@ describe('availableSamplePayModes', () => {
})
})
describe('samplePaymentHasLockedTransfer', () => {
it('locks jfqrz / yjf / tfqrz / ytf like backend', () => {
for (const payStatus of ['jfqrz', 'yjf', 'tfqrz', 'ytf']) {
assert.equal(isSamplePaymentTransferLocked(payStatus), true)
assert.equal(
samplePaymentHasLockedTransfer({
schedules: [{ transfers: [{ payStatus }] }]
}),
true
)
}
})
it('dfj / empty are not locked', () => {
assert.equal(isSamplePaymentTransferLocked('dfj'), false)
assert.equal(isSamplePaymentTransferLocked(''), false)
assert.equal(
samplePaymentHasLockedTransfer({
schedules: [{ transfers: [{ payStatus: 'dfj' }] }]
}),
false
)
})
})
describe('applyConsolidateRemainingToPayment', () => {
it('fq→dj keeps paid and consolidates remaining', () => {
const next = applyConsolidateRemainingToPayment(
@@ -271,6 +271,24 @@ export function samplePaymentHasConfirmedTransfer(pay: {
)
}
/**
* 与后端 `_locked_schedule_ids` 对齐:转账状态不是待缴费 `dfj` 即锁定
*(含 jfqrz / yjf / tfqrz / ytf 等)。
*/
export function isSamplePaymentTransferLocked(payStatus: unknown): boolean {
const status = String(payStatus || '').trim()
return Boolean(status) && status !== 'dfj'
}
/** 样本缴费方案是否含锁定转账(不可省略/删除) */
export function samplePaymentHasLockedTransfer(pay: {
schedules?: FgdScheduleLike[]
}): boolean {
return (pay.schedules || []).some((s) =>
(s.transfers || []).some((t) => isSamplePaymentTransferLocked(t.payStatus))
)
}
/**
* 下拉可选缴费模式(趸交 ↔ 分改趸 互斥,不可直转):
* - 分期:可改趸交 / 分改趸
@@ -17,8 +17,10 @@ import {
toAllTabListQuery,
workOrderMatchesCategory
} from '@/utils/qc-work-order-category'
import { matchesProtocolStatusFilter } from '@/utils/donation-protocol-status-filter'
import { matchesWorkOrderStatusFilter } from '@/utils/status-tag-registry'
import { compareLabStorageThenCreateTimeDesc } from '@/utils/qc-inbound-lab-storage-sort'
import { sortByTemporaryProductionCluster } from '@/utils/qc-temporary-production-cluster-sort'
export type QcTaskMineFilters = {
keyword?: string
@@ -30,6 +32,8 @@ export type QcTaskMineFilters = {
export type MineWorkOrderSearchFilters = {
keyword?: string
workOrderStatus?: string | string[]
/** 科研捐赠 Tab:协议/捐赠状态多选;undefined=不限制;[]=无匹配 */
protocolStatus?: string[]
triggerSource?: string
qcCategory?: string
createTimeStart?: string
@@ -104,6 +108,11 @@ export function filterMineEntriesByWorkOrderSearch(
return false
}
if (!matchesWorkOrderStatusFilter(row.workOrderStatus, filters.workOrderStatus)) return false
if (tabKey === 'inbound_research_donation' && filters.protocolStatus !== undefined) {
if (!matchesProtocolStatusFilter(row.protocolStatus || '', filters.protocolStatus)) {
return false
}
}
if (filters.triggerSource) {
if (isAllQcWorkOrderTab(tabKey)) {
if (!matchesAllTabTriggerSource(row, filters.triggerSource)) return false
@@ -187,6 +196,21 @@ export function sortMineEntriesForInboundLabStorage(
)
}
/** 临时质检:同生产工单成组,组位置按组内最新工单创建时间倒序 */
export function sortMineEntriesForTemporary(
entries: MineWorkOrderEntry[]
): MineWorkOrderEntry[] {
const sortedRows = sortByTemporaryProductionCluster(
entries.map((e) => ({
id: e.workOrder.id,
productionWorkOrderId: e.workOrder.productionWorkOrderId,
createTime: e.workOrder.createTime,
entry: e
}))
)
return sortedRows.map((row) => row.entry)
}
export function buildMineTableRows(pageEntries: MineWorkOrderEntry[]): {
rows: QcWorkOrder[]
presetTasksByWorkOrderId: Record<string, QcTask[]>
@@ -0,0 +1,65 @@
/** 临时质检:同生产工单成组;组位置 = 组内最新 createTime DESC;组内 createTime DESC */
export function temporaryProductionClusterKey(
productionWorkOrderId: string | null | undefined,
rowId: string
): string {
const pwo = String(productionWorkOrderId || '').trim()
if (pwo) return `pwo:${pwo}`
return `id:${String(rowId || '').trim()}`
}
export function compareTemporaryProductionClusterDesc(
idA: string,
pwoA: string | null | undefined,
timeA: string | null | undefined,
groupMaxA: string,
idB: string,
pwoB: string | null | undefined,
timeB: string | null | undefined,
groupMaxB: string
): number {
const ga = String(groupMaxA || '').trim()
const gb = String(groupMaxB || '').trim()
if (ga !== gb) return ga > gb ? -1 : 1
const keyA = temporaryProductionClusterKey(pwoA, idA)
const keyB = temporaryProductionClusterKey(pwoB, idB)
if (keyA !== keyB) return keyA < keyB ? -1 : 1
const ta = String(timeA || '').trim()
const tb = String(timeB || '').trim()
if (ta !== tb) return ta > tb ? -1 : 1
const ia = String(idA || '').trim()
const ib = String(idB || '').trim()
if (ia !== ib) return ia > ib ? -1 : 1
return 0
}
export type TemporaryClusterSortable = {
id: string
productionWorkOrderId?: string | null
createTime?: string | null
}
export function sortByTemporaryProductionCluster<T extends TemporaryClusterSortable>(
items: T[]
): T[] {
const groupMax = new Map<string, string>()
for (const item of items) {
const key = temporaryProductionClusterKey(item.productionWorkOrderId, item.id)
const t = String(item.createTime || '').trim()
const prev = groupMax.get(key) || ''
if (t > prev) groupMax.set(key, t)
}
return [...items].sort((a, b) =>
compareTemporaryProductionClusterDesc(
a.id,
a.productionWorkOrderId,
a.createTime,
groupMax.get(temporaryProductionClusterKey(a.productionWorkOrderId, a.id)) || '',
b.id,
b.productionWorkOrderId,
b.createTime,
groupMax.get(temporaryProductionClusterKey(b.productionWorkOrderId, b.id)) || ''
)
)
}
@@ -213,6 +213,7 @@
:unit-options="unitOptions"
:unit-options-loading="unitLoading"
:show-price="true"
:sample-payments="formData.samplePayments"
@price-change="onSamplePriceChange"
@search-sample-code="remoteSearchSampleCodes"
@search-sample-type="remoteSearchSampleTypes"
@@ -372,6 +372,7 @@
parsePreferentialPrice,
resolvePerSamplePaymentDefaults,
samplePaymentHasConfirmedTransfer,
samplePaymentHasLockedTransfer,
schedulePaidArrears,
shouldAutoFillLastPeriodDue,
shouldReplaceContractWithPreferentialPrice
@@ -569,19 +570,13 @@
}
}
function paymentHasLockedTransfer(pay: Record<string, any>) {
return (pay.schedules || []).some((s: Record<string, any>) =>
(s.transfers || []).some((t: Record<string, any>) => t.payStatus === 'yjf')
)
}
/** 优惠价变更时同步合同;打开编辑不覆盖库内金额(仅合同为空时补齐) */
function applyPreferentialPriceToPayment(
pay: Record<string, any>,
sample: Record<string, any>,
force = false
): Record<string, any> {
if (paymentHasLockedTransfer(pay)) return pay
if (samplePaymentHasLockedTransfer(pay)) return pay
const price = parsePreferentialPrice(sample)
if (price == null) return pay
if (!force && !shouldReplaceContractWithPreferentialPrice(pay.contractAmount, price)) {
@@ -1114,6 +1109,26 @@
next.push(emptyDjPayment(sample))
}
}
// 锁定缴费不可因样本删改/换码从提交列表中消失(对齐后端「不能省略或删除」)
const keptCodes = new Set(next.map((p) => String(p.sampleCode || '')))
const keptIds = new Set(
next.map((p) => String(p.id || p.samplePaymentId || '')).filter(Boolean)
)
let restoredLocked = false
for (const pay of existing) {
if (!samplePaymentHasLockedTransfer(pay)) continue
const id = String(pay.id || pay.samplePaymentId || '')
const code = String(pay.sampleCode || '')
if ((id && keptIds.has(id)) || (code && keptCodes.has(code))) continue
next.push(pay)
restoredLocked = true
}
if (restoredLocked) {
ElMessage.warning(
'存在锁定缴费方案的样本不可删除或改码,请使用客户服务「更正编码」'
)
}
lastPreferentialByCode.value = nextPriceMap
const same =
next.length === existing.length &&
@@ -0,0 +1,217 @@
<template>
<el-dialog
v-model="visible"
title="更正样本编码"
width="640px"
class="dialog-form-layout"
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
@closed="onClosed"
>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="form-grid-2">
<el-form-item label="当前样本编码" class="form-col-span-2">
<el-input :model-value="oldSampleCode" disabled />
</el-form-item>
<el-form-item label="新样本编码" prop="newSampleCode" class="form-col-span-2">
<el-input
v-model="form.newSampleCode"
placeholder="请输入正确的样本编码"
clearable
maxlength="32"
/>
</el-form-item>
<el-form-item label="更正原因" prop="reason" class="form-col-span-2">
<el-input
v-model="form.reason"
type="textarea"
:rows="2"
placeholder="例如:客户填错编码"
maxlength="512"
show-word-limit
/>
</el-form-item>
</el-form>
<div v-if="preview" class="preview-block">
<div v-if="!preview.ok" class="preview-blockers">
<div v-for="(b, i) in preview.blockers" :key="i" class="blocker-line">{{ b }}</div>
</div>
<template v-else>
<div class="preview-summary">
预计影响 <strong>{{ preview.totalAffectedRows }}</strong> 行(含源表)
</div>
<el-table :data="preview.impacts" size="small" stripe class="preview-table" max-height="220">
<el-table-column prop="label" label="业务表" min-width="160" />
<el-table-column prop="table" label="表名" min-width="200" show-overflow-tooltip />
<el-table-column prop="count" label="行数" min-width="72" align="center" />
</el-table>
<ul class="preview-hints">
<li>实验室入库编码不会修改</li>
<li>细胞产品码不会自动修改(同名产品码:旧 {{ hintOld }} / 新 {{ hintNew }})</li>
</ul>
</template>
</div>
<template #footer>
<el-button @click="visible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button :loading="previewing" @click="handlePreview">
<el-icon><View /></el-icon>
预览影响
</el-button>
<el-button
type="primary"
:loading="applying"
:disabled="!preview?.ok"
@click="handleApply"
>
<el-icon><Check /></el-icon>
确认更正
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { Check, Close, View } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import {
fetchApplySampleCodeCorrection,
fetchPreviewSampleCodeCorrection,
type SampleCodeCorrectionPreview,
type SampleCodeCorrectionSource
} from '@/api/sample-code-correction'
const props = defineProps<{
modelValue: boolean
receptionSource: SampleCodeCorrectionSource
oldSampleCode: string
}>()
const emit = defineEmits<{
'update:modelValue': [boolean]
success: []
}>()
const visible = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v)
})
const formRef = ref<FormInstance>()
const form = reactive({
newSampleCode: '',
reason: ''
})
const rules: FormRules = {
newSampleCode: [{ required: true, message: '请输入新样本编码', trigger: 'blur' }],
reason: [{ required: true, message: '请填写更正原因', trigger: 'blur' }]
}
const preview = ref<SampleCodeCorrectionPreview | null>(null)
const previewing = ref(false)
const applying = ref(false)
const hintOld = computed(() => preview.value?.hints?.sameProductCodeAsOld ?? 0)
const hintNew = computed(() => preview.value?.hints?.sameProductCodeAsNew ?? 0)
watch(
() => props.modelValue,
(open) => {
if (open) {
form.newSampleCode = ''
form.reason = ''
preview.value = null
}
}
)
function onClosed() {
preview.value = null
}
async function handlePreview() {
const ok = await formRef.value?.validate().catch(() => false)
if (!ok) return
previewing.value = true
try {
const data = await fetchPreviewSampleCodeCorrection({
receptionSource: props.receptionSource,
oldSampleCode: props.oldSampleCode,
newSampleCode: form.newSampleCode.trim(),
reason: form.reason.trim()
})
preview.value = data
if (!data.ok) {
ElMessage.warning(data.blockers?.[0] || '无法更正')
}
} catch (e: any) {
ElMessage.error(e?.message || '预览失败')
} finally {
previewing.value = false
}
}
async function handleApply() {
if (!preview.value?.ok) {
ElMessage.warning('请先预览并通过校验')
return
}
try {
await ElMessageBox.confirm(
`确认将样本编码「${props.oldSampleCode}」更正为「${form.newSampleCode.trim()}」?将级联更新 ${preview.value.totalAffectedRows} 行相关数据。`,
'确认更正',
{ type: 'warning', confirmButtonText: '确认更正', cancelButtonText: '取消' }
)
} catch {
return
}
applying.value = true
try {
await fetchApplySampleCodeCorrection({
receptionSource: props.receptionSource,
oldSampleCode: props.oldSampleCode,
newSampleCode: form.newSampleCode.trim(),
reason: form.reason.trim()
})
ElMessage.success('样本编码已更正')
visible.value = false
emit('success')
} catch (e: any) {
ElMessage.error(e?.message || '更正失败')
} finally {
applying.value = false
}
}
</script>
<style scoped lang="scss">
.preview-block {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--el-border-color-lighter);
}
.preview-blockers {
color: var(--el-color-danger);
font-size: 13px;
line-height: 1.6;
}
.preview-summary {
margin-bottom: 8px;
font-size: 13px;
color: var(--mint-text, #334155);
}
.preview-hints {
margin: 8px 0 0;
padding-left: 18px;
font-size: 12px;
color: var(--mint-text-muted, #64748b);
line-height: 1.6;
}
</style>
@@ -393,17 +393,24 @@
</el-table-column>
</template>
<el-table-column label="操作" min-width="280" fixed="right" align="center" header-align="center">
<el-table-column label="操作" min-width="360" fixed="right" align="center" header-align="center">
<template #default="{ row }">
<el-button class="btn-op-item primary" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-button
class="btn-op-item"
:disabled="!row.sampleCode"
@click="openCodeCorrection(row)"
>
<el-icon><EditPen /></el-icon>更正编码
</el-button>
<el-button
v-if="isCellStorage && supportsStorageCertQc(row.sampleTypeName)"
class="btn-op-item"
@click="openStorageCertQcView(row)"
@click="openStorageCertQcEdit(row)"
>
<el-icon><Document /></el-icon>查看储存证的质检信息
<el-icon><Document /></el-icon>填写储存证质检信息
</el-button>
</template>
</el-table-column>
@@ -447,10 +454,16 @@
/>
<StorageCertQcDialog
v-model:visible="storageCertQcVisible"
mode="view"
mode="edit"
:sample-code="storageCertQcSampleCode"
:sample-type-name="storageCertQcSampleTypeName"
/>
<SampleCodeCorrectionDialog
v-model="codeCorrectionVisible"
:reception-source="activeSource"
:old-sample-code="codeCorrectionOldCode"
@success="loadData"
/>
</div>
</template>
@@ -460,6 +473,7 @@
ChatDotRound,
Collection,
Document,
EditPen,
Files,
Location,
Phone,
@@ -480,6 +494,7 @@
import DonationViewDialog from '@/views/research-donation/list/DonationViewDialog.vue'
import SampleCodeWithSpecialDisplay from '@/components/business/sample-code-with-special-display/index.vue'
import SampleInfoQuickRegisterDialog from './SampleInfoQuickRegisterDialog.vue'
import SampleCodeCorrectionDialog from './SampleCodeCorrectionDialog.vue'
import {
donationStatusDisplayLabel,
donationStatusTagType,
@@ -535,6 +550,8 @@
const storageCertQcVisible = ref(false)
const storageCertQcSampleCode = ref('')
const storageCertQcSampleTypeName = ref('')
const codeCorrectionVisible = ref(false)
const codeCorrectionOldCode = ref('')
function sexLabel(sex: unknown) {
const s = String(sex || '').trim()
@@ -543,16 +560,26 @@
return s || '—'
}
function openStorageCertQcView(row: Record<string, unknown>) {
function openStorageCertQcEdit(row: Record<string, unknown>) {
storageCertQcSampleCode.value = String(row.sampleCode || '')
storageCertQcSampleTypeName.value = String(row.sampleTypeName || '')
if (!storageCertQcSampleCode.value) {
ElMessage.warning('缺少样本编码,无法查看')
ElMessage.warning('缺少样本编码,无法填写')
return
}
storageCertQcVisible.value = true
}
function openCodeCorrection(row: Record<string, unknown>) {
const code = String(row.sampleCode || '').trim()
if (!code) {
ElMessage.warning('缺少样本编码,无法更正')
return
}
codeCorrectionOldCode.value = code
codeCorrectionVisible.value = true
}
function statusLabel(row: Record<string, unknown>) {
if (isCellStorage.value) {
return protocolStatusDisplayLabel(String(row.protocolStatus || ''), String(row.voidType || ''))
@@ -74,7 +74,12 @@
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.appName || row.title || '-' }}</template>
<template #default="{ row }">
<span class="mention-flow-name">
<span v-if="!row.isRead" class="mention-unread-dot" aria-hidden="true" />
{{ row.appName || row.title || '-' }}
</span>
</template>
</el-table-column>
<el-table-column
prop="initiatorUserName"
@@ -116,6 +121,13 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="阅读状态" min-width="100" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.isRead ? 'info' : 'danger'">
{{ row.isRead ? '已读' : '未读' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" min-width="260" fixed="right" align="center">
<template #default="{ row }">
<el-button class="btn-op-item primary" @click="openDetail(row)">
@@ -156,9 +168,14 @@
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Printer, RefreshLeft, Search, View } from '@element-plus/icons-vue'
import { fetchOaInstanceDetail, fetchOaTasks } from '@/api/oaApproval'
import {
fetchMarkOaMentionRead,
fetchOaInstanceDetail,
fetchOaTasks
} from '@/api/oaApproval'
import { useUserStore } from '@/store/modules/user'
import { exportOaApprovalPdf } from '@/utils/oa-approval-print'
import { refreshOaMenuBadges } from '@/utils/oa-menu-badge'
defineOptions({ name: 'OaApprovalMention' })
@@ -211,9 +228,18 @@
return map[s] || s
}
function openDetail(row: any) {
const id = row.id
async function openDetail(row: any) {
const id = String(row?.id || '').trim()
if (!id) return
if (!row.isRead) {
try {
await fetchMarkOaMentionRead(id)
row.isRead = true
} catch {
/* 仍允许进入详情 */
}
void refreshOaMenuBadges()
}
router.push({ path: `/system-manage/oa-approval/instances/${id}` })
}
@@ -289,6 +315,29 @@
{ debounce: 300, deep: true }
)
onMounted(load)
onActivated(load)
onMounted(() => {
void load()
void refreshOaMenuBadges()
})
onActivated(() => {
void load()
void refreshOaMenuBadges()
})
</script>
<style scoped lang="scss">
.mention-flow-name {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
}
.mention-unread-dot {
flex-shrink: 0;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--el-color-danger);
}
</style>
@@ -0,0 +1,405 @@
<!-- 细胞产品基础信息维护 · 包装 Tab -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="cell-product-package-tab">
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input v-model="searchForm.keyword" placeholder="包装编码/名称" clearable />
</el-form-item>
</div>
<div class="filter-actions-row">
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset"
><el-icon><RefreshLeft /></el-icon>重置</el-button
>
<el-button class="btn-main" type="primary" @click="handleSearch"
><el-icon><Search /></el-icon>搜索</el-button
>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增包装
</el-button>
<el-button
class="btn-ghost"
type="danger"
:disabled="selectedRows.length === 0"
@click="handleBatchDelete"
>
<el-icon><Delete /></el-icon>
批量删除
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="packageCode"
label="包装编码"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="packageName"
label="包装名称"
min-width="150"
align="center"
header-align="center"
/>
<el-table-column
prop="remark"
label="备注"
min-width="150"
align="center"
header-align="center"
/>
<el-table-column
prop="createTime"
label="创建时间"
min-width="180"
align="center"
header-align="center"
>
<template #default="{ row }">{{ formatDisplayOrDash(row.createTime) }}</template>
</el-table-column>
<el-table-column
label="操作"
min-width="120"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button class="btn-op-item" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-dropdown
trigger="hover"
@command="(cmd: string) => handleDropdownCommand(cmd, row)"
>
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="edit">
<el-icon><Edit /></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>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
@current-change="(val) => ((pagination.page = val), loadData())"
/>
</div>
</div>
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-position="top"
class="package-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="包装编码" prop="packageCode">
<el-input v-model="formData.packageCode" placeholder="请输入包装编码" />
</el-form-item>
<el-form-item label="包装名称" prop="packageName">
<el-input v-model="formData.packageName" placeholder="请输入包装名称" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="formData.remark" placeholder="可选" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="dialogVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button class="btn-main" type="primary" @click="handleSubmit" :loading="submitLoading">
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-dialog>
<SystemViewDialog
v-model:visible="viewVisible"
:title="viewRow?.packageName || '包装详情'"
:icon="Box"
:code="viewRow?.packageCode"
code-label="包装编码"
:sections="viewSections"
/>
</div>
</template>
<script setup lang="ts">
import { formatDisplayOrDash } from '@/utils/datetime-display'
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus,
Close,
Check,
View,
Edit,
Delete,
Box,
RefreshLeft,
Search,
More
} from '@element-plus/icons-vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import {
fetchCellProductPackageList,
fetchCreateCellProductPackage,
fetchUpdateCellProductPackage,
fetchDeleteCellProductPackage
} from '@/api/admin'
defineOptions({ name: 'CellProductPackageTab' })
const searchForm = reactive<{ keyword: string }>({ keyword: '' })
const tableData = ref<Api.CellProductPackage.CellProductPackageItem[]>([])
const selectedRows = ref<Api.CellProductPackage.CellProductPackageItem[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const dialogVisible = ref(false)
const dialogTitle = ref('新增包装')
const isEdit = ref(false)
const submitLoading = ref(false)
const formRef = ref()
const editingId = ref<string | null>(null)
const viewVisible = ref(false)
const viewRow = ref<Api.CellProductPackage.CellProductPackageItem | null>(null)
const formData = reactive({
packageCode: '',
packageName: '',
remark: ''
})
const formRules = {
packageCode: [{ required: true, message: '请输入包装编码', trigger: 'blur' }],
packageName: [{ required: true, message: '请输入包装名称', trigger: 'blur' }]
}
const handleSelectionChange = (rows: Api.CellProductPackage.CellProductPackageItem[]) => {
selectedRows.value = rows
}
const viewSections = computed(() => {
if (!viewRow.value) return []
const row = viewRow.value
return [
{
title: '基本信息',
fields: [
{ label: '包装编码', value: row.packageCode },
{ label: '包装名称', value: row.packageName },
{ label: '备注', value: row.remark, span: true }
]
}
]
})
async function loadData() {
loading.value = true
try {
const res = await fetchCellProductPackageList({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined
})
tableData.value = res.list || []
pagination.total = res.total ?? 0
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
}
function handleReset() {
searchForm.keyword = ''
pagination.page = 1
loadData()
}
function resetForm() {
formData.packageCode = ''
formData.packageName = ''
formData.remark = ''
editingId.value = null
}
function handleAdd() {
isEdit.value = false
dialogTitle.value = '新增包装'
resetForm()
dialogVisible.value = true
formRef.value?.clearValidate?.()
}
function handleEdit(row: Api.CellProductPackage.CellProductPackageItem) {
isEdit.value = true
dialogTitle.value = '编辑包装'
editingId.value = row.id
formData.packageCode = row.packageCode || ''
formData.packageName = row.packageName || ''
formData.remark = row.remark || ''
dialogVisible.value = true
formRef.value?.clearValidate?.()
}
async function handleSubmit() {
await formRef.value?.validate?.()
submitLoading.value = true
try {
const payload = {
packageCode: formData.packageCode.trim(),
packageName: formData.packageName.trim(),
remark: formData.remark?.trim() || undefined
}
if (isEdit.value && editingId.value) {
await fetchUpdateCellProductPackage(editingId.value, payload)
} else {
await fetchCreateCellProductPackage(payload)
}
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
dialogVisible.value = false
await loadData()
} catch {
/* 校验或接口错误已由全局处理 */
} finally {
submitLoading.value = false
}
}
function handleView(row: Api.CellProductPackage.CellProductPackageItem) {
viewRow.value = row
viewVisible.value = true
}
function handleDropdownCommand(cmd: string, row: Api.CellProductPackage.CellProductPackageItem) {
if (cmd === 'edit') handleEdit(row)
else if (cmd === 'delete') handleDelete(row)
}
async function handleDelete(row: Api.CellProductPackage.CellProductPackageItem) {
try {
await ElMessageBox.confirm('确定要删除该包装吗?', '提示', { type: 'warning' })
} catch {
return
}
await withFullscreenLoading(async () => {
await fetchDeleteCellProductPackage(row.id)
ElMessage.success('删除成功')
await loadData()
}, fullscreenLoadingText.delete)
}
async function handleBatchDelete() {
const ids = selectedRows.value.map((r) => r.id)
if (!ids.length) return
await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 个包装吗?`, '提示', {
type: 'warning'
})
await withFullscreenLoading(async () => {
for (const id of ids) {
await fetchDeleteCellProductPackage(id)
}
ElMessage.success('批量删除成功')
selectedRows.value = []
await loadData()
}, fullscreenLoadingText.batchDelete)
}
// 输入检索条件时自动搜索(防抖 300ms)
watchDebounced(
searchForm,
() => {
pagination.page = 1
loadData()
},
{ debounce: 300, deep: true }
)
defineExpose({ handleAdd, loadData })
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.package-dialog-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
</style>
@@ -1,214 +1,165 @@
<!-- 细胞产品基础信息维护 · 价格体系 Tab -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div :class="layout === 'kanban' ? 'kanban-col-body' : 'cell-product-price-tab'">
<template v-if="layout === 'kanban'">
<div class="kanban-col-search">
<el-input v-model="searchForm.keyword" placeholder="项目名称/编码" clearable />
</div>
<div class="kanban-list" v-loading="loading">
<article
v-for="row in tableData"
:key="row.id"
class="entity-card"
@dblclick="handleView(row)"
<div class="cell-product-price-tab">
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input v-model="searchForm.keyword" placeholder="项目名称/编码" clearable />
</el-form-item>
</div>
<div class="filter-actions-row">
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset"
><el-icon><RefreshLeft /></el-icon>重置</el-button
>
<el-button class="btn-main" type="primary" @click="handleSearch"
><el-icon><Search /></el-icon>搜索</el-button
>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增价格
</el-button>
<el-button
class="btn-ghost"
type="danger"
:disabled="selectedRows.length === 0"
@click="handleBatchDelete"
>
<div class="entity-card-del" @click.stop>
<el-button class="btn-op-item" title="删除" @click="handleDelete(row)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
<div class="entity-card-body">
<h3 class="entity-card-title">{{ row.projectName }}</h3>
<div class="entity-card-meta">{{ row.projectCode }}</div>
<div v-if="row.cellTypeName || row.specText" class="entity-card-tags">
<span v-if="row.cellTypeName" class="tag success">{{ row.cellTypeName }}</span>
<span v-if="row.specText" class="tag muted">{{ row.specText }}</span>
</div>
<div class="entity-card-ft" @click.stop>
<span
class="entity-card-ft-val"
:class="{ 'entity-card-ft-val--muted': row.unitPrice == null }"
>
{{ row.unitPrice != null ? `${formatMoneyDisplay(row.unitPrice)} 元` : '未定价' }}
</span>
<div class="entity-card-ft-actions">
<el-button class="btn-op-item" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-button class="btn-op-item" @click="handleEdit(row)">
<el-icon><Edit /></el-icon>编辑
</el-button>
</div>
</div>
</div>
</article>
<div v-if="!loading && !tableData.length" class="kanban-empty">
<span>暂无价格数据</span>
<span class="kanban-empty__hint">点击列头「新增」添加产品价格</span>
</div>
<el-icon><Delete /></el-icon>
批量删除
</el-button>
</div>
</template>
</div>
<template v-else>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input v-model="searchForm.keyword" placeholder="项目名称/编码" clearable />
</el-form-item>
</div>
<div class="filter-actions-row">
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset"
><el-icon><RefreshLeft /></el-icon>重置</el-button
>
<el-button class="btn-main" type="primary" @click="handleSearch"
><el-icon><Search /></el-icon>搜索</el-button
>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增价格
</el-button>
<el-button
class="btn-ghost"
type="danger"
:disabled="selectedRows.length === 0"
@click="handleBatchDelete"
>
<el-icon><Delete /></el-icon>
批量删除
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="projectName"
label="项目名称"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column
prop="projectCode"
label="项目编码"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="cellTypeName"
label="细胞类型名称"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column
prop="specText"
label="规格"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="unitPrice"
label="单价"
min-width="100"
align="center"
header-align="center"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="projectName"
label="项目名称"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column
prop="projectCode"
label="项目编码"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="cellTypeName"
label="细胞类型名称"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column
prop="specText"
label="规格"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="unitPrice"
label="单价"
min-width="100"
align="center"
header-align="center"
>
<template #default="{ row }">
{{ row.unitPrice != null ? `${formatMoneyDisplay(row.unitPrice)} 元` : '-' }}
</template>
</el-table-column>
<el-table-column
prop="remark"
label="备注"
min-width="150"
align="center"
header-align="center"
/>
<el-table-column
prop="createTime"
label="创建时间"
min-width="180"
align="center"
header-align="center">
<template #default="{ row }">{{ formatDisplayOrDash(row.createTime) }}</template>
</el-table-column>
<el-table-column
label="操作"
min-width="160"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button class="btn-op-item primary" @click="handleView(row)">
<el-icon><View /></el-icon>查看
<template #default="{ row }">
{{ row.unitPrice != null ? `${formatMoneyDisplay(row.unitPrice)} 元` : '-' }}
</template>
</el-table-column>
<el-table-column
prop="remark"
label="备注"
min-width="150"
align="center"
header-align="center"
/>
<el-table-column
prop="createTime"
label="创建时间"
min-width="180"
align="center"
header-align="center"
>
<template #default="{ row }">{{ formatDisplayOrDash(row.createTime) }}</template>
</el-table-column>
<el-table-column
label="操作"
min-width="160"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button class="btn-op-item primary" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-dropdown trigger="hover" @command="(cmd) => handleDropdownCommand(cmd, row)">
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
<el-dropdown trigger="hover" @command="(cmd) => handleDropdownCommand(cmd, row)">
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="edit">
<el-icon><Edit /></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>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
@current-change="(val) => ((pagination.page = val), loadData())"
/>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="edit">
<el-icon><Edit /></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>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
@current-change="(val) => ((pagination.page = val), loadData())"
/>
</div>
</template>
</div>
<el-dialog
v-model="dialogVisible"
@@ -343,11 +294,6 @@
defineOptions({ name: 'CellProductPriceTab' })
const props = withDefaults(defineProps<{ layout?: 'table' | 'kanban' }>(), { layout: 'table' })
const emit = defineEmits<{ (e: 'meta-change', payload: { total: number }): void }>()
const KANBAN_PAGE_SIZE = 100
// --- Search ---
const searchForm = reactive<{ keyword: string }>({ keyword: '' })
@@ -484,7 +430,6 @@
})
tableData.value = (res as any)?.list || []
pagination.total = (res as any)?.total ?? 0
emit('meta-change', { total: pagination.total })
} finally {
loading.value = false
}
@@ -652,35 +597,11 @@
defineExpose({ handleAdd, loadData })
onMounted(() => {
if (props.layout === 'kanban') {
pagination.pageSize = KANBAN_PAGE_SIZE
}
loadData()
})
</script>
<style scoped lang="scss">
.kanban-col-body {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.kanban-col-search {
padding: 8px 10px 0;
flex-shrink: 0;
}
.kanban-list {
padding: 10px;
overflow-y: auto;
flex: 1;
min-height: 0;
}
@import './cell-product-kanban.scss';
.price-dialog-form {
:deep(.el-form-item) {
margin-bottom: 0;
@@ -1,185 +1,143 @@
<!-- 细胞产品基础信息维护 · 规格 Tab -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div :class="layout === 'kanban' ? 'kanban-col-body' : 'cell-product-spec-tab'">
<template v-if="layout === 'kanban'">
<div class="kanban-col-search">
<el-input v-model="searchForm.keyword" placeholder="规格编码/名称" clearable />
</div>
<div class="kanban-list" v-loading="loading">
<article
v-for="row in tableData"
:key="row.id"
class="entity-card"
@dblclick="handleView(row)"
<div class="cell-product-spec-tab">
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input v-model="searchForm.keyword" placeholder="规格编码/名称" clearable />
</el-form-item>
</div>
<div class="filter-actions-row">
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset"
><el-icon><RefreshLeft /></el-icon>重置</el-button
>
<el-button class="btn-main" type="primary" @click="handleSearch"
><el-icon><Search /></el-icon>搜索</el-button
>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增规格
</el-button>
<el-button
class="btn-ghost"
type="danger"
:disabled="selectedRows.length === 0"
@click="handleBatchDelete"
>
<div class="entity-card-del" @click.stop>
<el-button class="btn-op-item" title="删除" @click="handleDelete(row)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
<div class="entity-card-body">
<h3 class="entity-card-title">{{ row.specName }}</h3>
<div class="entity-card-meta">{{ row.specCode }}</div>
<div v-if="row.remark" class="entity-card-tags">
<span class="tag muted">{{ row.remark }}</span>
</div>
<div class="entity-card-ft" @click.stop>
<div class="entity-card-ft-actions">
<el-button class="btn-op-item" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-button class="btn-op-item" @click="handleEdit(row)">
<el-icon><Edit /></el-icon>编辑
</el-button>
</div>
</div>
</div>
</article>
<div v-if="!loading && !tableData.length" class="kanban-empty">
<span>暂无规格数据</span>
<span class="kanban-empty__hint">点击列头「新增」添加产品规格</span>
</div>
<el-icon><Delete /></el-icon>
批量删除
</el-button>
</div>
</template>
</div>
<template v-else>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input v-model="searchForm.keyword" placeholder="规格编码/名称" clearable />
</el-form-item>
</div>
<div class="filter-actions-row">
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset"
><el-icon><RefreshLeft /></el-icon>重置</el-button
>
<el-button class="btn-main" type="primary" @click="handleSearch"
><el-icon><Search /></el-icon>搜索</el-button
>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增规格
</el-button>
<el-button
class="btn-ghost"
type="danger"
:disabled="selectedRows.length === 0"
@click="handleBatchDelete"
>
<el-icon><Delete /></el-icon>
批量删除
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="specCode"
label="规格编码"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="specName"
label="规格名称"
min-width="150"
align="center"
header-align="center"
/>
<el-table-column
prop="remark"
label="备注"
min-width="150"
align="center"
header-align="center"
/>
<el-table-column
prop="createTime"
label="创建时间"
min-width="180"
align="center"
header-align="center"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="specCode"
label="规格编码"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="specName"
label="规格名称"
min-width="150"
align="center"
header-align="center"
/>
<el-table-column
prop="remark"
label="备注"
min-width="150"
align="center"
header-align="center"
/>
<el-table-column
prop="createTime"
label="创建时间"
min-width="180"
align="center"
header-align="center">
<template #default="{ row }">{{ formatDisplayOrDash(row.createTime) }}</template>
</el-table-column>
<el-table-column
label="操作"
min-width="120"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button class="btn-op-item" @click="handleView(row)">
<el-icon><View /></el-icon>查看
<template #default="{ row }">{{ formatDisplayOrDash(row.createTime) }}</template>
</el-table-column>
<el-table-column
label="操作"
min-width="120"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button class="btn-op-item" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-dropdown
trigger="hover"
@command="(cmd: string) => handleDropdownCommand(cmd, row)"
>
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
<el-dropdown
trigger="hover"
@command="(cmd: string) => handleDropdownCommand(cmd, row)"
>
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="edit">
<el-icon><Edit /></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>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
@current-change="(val) => ((pagination.page = val), loadData())"
/>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="edit">
<el-icon><Edit /></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>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
@current-change="(val) => ((pagination.page = val), loadData())"
/>
</div>
</template>
</div>
<el-dialog
v-model="dialogVisible"
@@ -263,11 +221,6 @@
defineOptions({ name: 'CellProductSpecTab' })
const props = withDefaults(defineProps<{ layout?: 'table' | 'kanban' }>(), { layout: 'table' })
const emit = defineEmits<{ (e: 'meta-change', payload: { total: number }): void }>()
const KANBAN_PAGE_SIZE = 100
const searchForm = reactive<{ keyword: string }>({ keyword: '' })
const tableData = ref<Api.CellProductSpec.CellProductSpecItem[]>([])
const selectedRows = ref<Api.CellProductSpec.CellProductSpecItem[]>([])
@@ -322,7 +275,6 @@
})
tableData.value = res.list || []
pagination.total = res.total ?? 0
emit('meta-change', { total: pagination.total })
} finally {
loading.value = false
}
@@ -440,35 +392,11 @@
defineExpose({ handleAdd, loadData })
onMounted(() => {
if (props.layout === 'kanban') {
pagination.pageSize = KANBAN_PAGE_SIZE
}
loadData()
})
</script>
<style scoped lang="scss">
.kanban-col-body {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.kanban-col-search {
padding: 8px 10px 0;
flex-shrink: 0;
}
.kanban-list {
padding: 10px;
overflow-y: auto;
flex: 1;
min-height: 0;
}
@import './cell-product-kanban.scss';
.spec-dialog-form {
:deep(.el-form-item) {
margin-bottom: 0;
@@ -0,0 +1,410 @@
<!-- 细胞产品基础信息维护 · 类型 Tab -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="cell-product-type-tab">
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input v-model="searchForm.keyword" placeholder="类型编码/名称" clearable />
</el-form-item>
</div>
<div class="filter-actions-row">
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset"
><el-icon><RefreshLeft /></el-icon>重置</el-button
>
<el-button class="btn-main" type="primary" @click="handleSearch"
><el-icon><Search /></el-icon>搜索</el-button
>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增类型
</el-button>
<el-button
class="btn-ghost"
type="danger"
:disabled="selectedRows.length === 0"
@click="handleBatchDelete"
>
<el-icon><Delete /></el-icon>
批量删除
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="productTypeCode"
label="类型编码"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="productTypeName"
label="类型名称"
min-width="150"
align="center"
header-align="center"
/>
<el-table-column
prop="remark"
label="备注"
min-width="150"
align="center"
header-align="center"
/>
<el-table-column
prop="createTime"
label="创建时间"
min-width="180"
align="center"
header-align="center"
>
<template #default="{ row }">{{ formatDisplayOrDash(row.createTime) }}</template>
</el-table-column>
<el-table-column
label="操作"
min-width="120"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button class="btn-op-item" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-dropdown
trigger="hover"
@command="(cmd: string) => handleDropdownCommand(cmd, row)"
>
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="edit">
<el-icon><Edit /></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>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
@current-change="(val) => ((pagination.page = val), loadData())"
/>
</div>
</div>
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-position="top"
class="type-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="类型编码" prop="productTypeCode">
<el-input v-model="formData.productTypeCode" placeholder="请输入类型编码" />
</el-form-item>
<el-form-item label="类型名称" prop="productTypeName">
<el-input v-model="formData.productTypeName" placeholder="请输入类型名称" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="formData.remark" placeholder="可选" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="dialogVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button class="btn-main" type="primary" @click="handleSubmit" :loading="submitLoading">
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-dialog>
<SystemViewDialog
v-model:visible="viewVisible"
:title="viewRow?.productTypeName || '类型详情'"
:icon="Grid"
:code="viewRow?.productTypeCode"
code-label="类型编码"
:sections="viewSections"
/>
</div>
</template>
<script setup lang="ts">
import { formatDisplayOrDash } from '@/utils/datetime-display'
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus,
Close,
Check,
View,
Edit,
Delete,
Grid,
RefreshLeft,
Search,
More
} from '@element-plus/icons-vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import {
fetchCellProductTypeList,
fetchCreateCellProductType,
fetchUpdateCellProductType,
fetchDeleteCellProductType
} from '@/api/admin'
defineOptions({ name: 'CellProductTypeTab' })
const emit = defineEmits<{ (e: 'changed'): void }>()
const searchForm = reactive<{ keyword: string }>({ keyword: '' })
const tableData = ref<Api.CellProductType.CellProductTypeItem[]>([])
const selectedRows = ref<Api.CellProductType.CellProductTypeItem[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const dialogVisible = ref(false)
const dialogTitle = ref('新增类型')
const isEdit = ref(false)
const submitLoading = ref(false)
const formRef = ref()
const editingId = ref<string | null>(null)
const viewVisible = ref(false)
const viewRow = ref<Api.CellProductType.CellProductTypeItem | null>(null)
const formData = reactive({
productTypeCode: '',
productTypeName: '',
remark: ''
})
const formRules = {
productTypeCode: [{ required: true, message: '请输入类型编码', trigger: 'blur' }],
productTypeName: [{ required: true, message: '请输入类型名称', trigger: 'blur' }]
}
const handleSelectionChange = (rows: Api.CellProductType.CellProductTypeItem[]) => {
selectedRows.value = rows
}
const viewSections = computed(() => {
if (!viewRow.value) return []
const row = viewRow.value
return [
{
title: '基本信息',
fields: [
{ label: '类型编码', value: row.productTypeCode },
{ label: '类型名称', value: row.productTypeName },
{ label: '备注', value: row.remark, span: true }
]
}
]
})
async function loadData() {
loading.value = true
try {
const res = await fetchCellProductTypeList({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined
})
tableData.value = res.list || []
pagination.total = res.total ?? 0
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
}
function handleReset() {
searchForm.keyword = ''
pagination.page = 1
loadData()
}
function resetForm() {
formData.productTypeCode = ''
formData.productTypeName = ''
formData.remark = ''
editingId.value = null
}
function handleAdd() {
isEdit.value = false
dialogTitle.value = '新增类型'
resetForm()
dialogVisible.value = true
formRef.value?.clearValidate?.()
}
function handleEdit(row: Api.CellProductType.CellProductTypeItem) {
isEdit.value = true
dialogTitle.value = '编辑类型'
editingId.value = row.id
formData.productTypeCode = row.productTypeCode || ''
formData.productTypeName = row.productTypeName || ''
formData.remark = row.remark || ''
dialogVisible.value = true
formRef.value?.clearValidate?.()
}
async function handleSubmit() {
await formRef.value?.validate?.()
submitLoading.value = true
try {
const payload = {
productTypeCode: formData.productTypeCode.trim(),
productTypeName: formData.productTypeName.trim(),
remark: formData.remark?.trim() || undefined
}
if (isEdit.value && editingId.value) {
await fetchUpdateCellProductType(editingId.value, payload)
} else {
await fetchCreateCellProductType(payload)
}
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
dialogVisible.value = false
await loadData()
emit('changed')
} catch {
/* 校验或接口错误已由全局处理 */
} finally {
submitLoading.value = false
}
}
function handleView(row: Api.CellProductType.CellProductTypeItem) {
viewRow.value = row
viewVisible.value = true
}
function handleDropdownCommand(cmd: string, row: Api.CellProductType.CellProductTypeItem) {
if (cmd === 'edit') handleEdit(row)
else if (cmd === 'delete') handleDelete(row)
}
async function handleDelete(row: Api.CellProductType.CellProductTypeItem) {
try {
await ElMessageBox.confirm('确定要删除该类型吗?', '提示', { type: 'warning' })
} catch {
return
}
await withFullscreenLoading(async () => {
await fetchDeleteCellProductType(row.id)
ElMessage.success('删除成功')
await loadData()
emit('changed')
}, fullscreenLoadingText.delete)
}
async function handleBatchDelete() {
const ids = selectedRows.value.map((r) => r.id)
if (!ids.length) return
await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 个类型吗?`, '提示', {
type: 'warning'
})
await withFullscreenLoading(async () => {
for (const id of ids) {
await fetchDeleteCellProductType(id)
}
ElMessage.success('批量删除成功')
selectedRows.value = []
await loadData()
emit('changed')
}, fullscreenLoadingText.batchDelete)
}
watchDebounced(
searchForm,
() => {
pagination.page = 1
loadData()
},
{ debounce: 300, deep: true }
)
defineExpose({ handleAdd, loadData })
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.type-dialog-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
</style>
@@ -1,516 +1,80 @@
<!-- 细胞产品基础信息维护 · 看板模式 P04 -->
<!-- 细胞产品基础信息维护 · 多 Tab 列表 -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container cell-product-kanban-page">
<div class="page-container">
<section class="panel">
<section class="cell-product-stats">
<div class="stat-card">
<div class="stat-icon">
<img src="/money.png" alt="" />
</div>
<div class="stat-main">
<div class="stat-k">产品价格</div>
<div class="stat-v">{{ priceTotal }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<img src="/lx.png" alt="" />
</div>
<div class="stat-main">
<div class="stat-k">产品类型</div>
<div class="stat-v">{{ typeTotal }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<img src="/gg.png" alt="" />
</div>
<div class="stat-main">
<div class="stat-k">产品规格</div>
<div class="stat-v">{{ specTotal }}</div>
</div>
</div>
</section>
<div class="kanban">
<!-- 列 1:产品价格 -->
<div class="kanban-col">
<div class="kanban-col-hd">
<span class="kanban-col-title">
细胞产品价格
<span class="kanban-count">{{ priceTotal }}</span>
</span>
<el-button
class="btn-main kanban-add-btn"
type="primary"
@click="priceTabRef?.handleAdd()"
>
<el-icon><Plus /></el-icon>
新增
</el-button>
</div>
<CellProductPriceTab ref="priceTabRef" layout="kanban" @meta-change="onPriceMetaChange" />
</div>
<!-- 列 2:产品类型 -->
<div class="kanban-col">
<div class="kanban-col-hd">
<span class="kanban-col-title">
细胞产品类型
<span class="kanban-count">{{ typeTotal }}</span>
</span>
<el-button class="btn-main kanban-add-btn" type="primary" @click="handleTypeAdd">
<el-icon><Plus /></el-icon>
新增
</el-button>
</div>
<div class="kanban-col-search">
<el-input
v-model="typeSearchForm.keyword"
placeholder="类型编码/名称"
clearable
@keyup.enter="handleTypeSearch"
/>
</div>
<div class="kanban-list" v-loading="typeLoading">
<article
v-for="row in typeTableData"
:key="row.id"
class="entity-card"
@dblclick="handleTypeView(row)"
>
<div class="entity-card-del" @click.stop>
<el-button class="btn-op-item" title="删除" @click="handleTypeDelete(row)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
<div class="entity-card-body">
<h3 class="entity-card-title">{{ row.productTypeName }}</h3>
<div class="entity-card-meta">{{ row.productTypeCode }}</div>
<div v-if="row.remark" class="entity-card-tags">
<span class="tag muted">{{ row.remark }}</span>
</div>
<div class="entity-card-ft" @click.stop>
<div class="entity-card-ft-actions">
<el-button class="btn-op-item" @click="handleTypeView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-button class="btn-op-item" @click="handleTypeEdit(row)">
<el-icon><Edit /></el-icon>编辑
</el-button>
</div>
</div>
</div>
</article>
<div v-if="!typeLoading && !typeTableData.length" class="kanban-empty">
<span>暂无类型数据</span>
<span class="kanban-empty__hint">点击右上角「新增」添加产品类型</span>
</div>
</div>
</div>
<!-- 列 3:产品规格 -->
<div class="kanban-col">
<div class="kanban-col-hd">
<span class="kanban-col-title">
细胞产品规格
<span class="kanban-count">{{ specTotal }}</span>
</span>
<el-button
class="btn-main kanban-add-btn"
type="primary"
@click="specTabRef?.handleAdd()"
>
<el-icon><Plus /></el-icon>
新增
</el-button>
</div>
<CellProductSpecTab ref="specTabRef" layout="kanban" @meta-change="onSpecMetaChange" />
</div>
</div>
<!-- 类型弹窗 -->
<el-dialog
v-model="typeDialogVisible"
:title="typeDialogTitle"
width="560px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
<el-form
ref="typeFormRef"
:model="typeFormData"
:rules="typeFormRules"
label-position="top"
class="type-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="类型编码" prop="productTypeCode">
<el-input v-model="typeFormData.productTypeCode" placeholder="请输入类型编码" />
</el-form-item>
<el-form-item label="类型名称" prop="productTypeName">
<el-input v-model="typeFormData.productTypeName" placeholder="请输入类型名称" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="typeFormData.remark" placeholder="可选" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="typeDialogVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button
class="btn-main"
type="primary"
@click="handleTypeSubmit"
:loading="typeSubmitLoading"
>
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-dialog>
<SystemViewDialog
v-model:visible="typeViewVisible"
:title="typeViewRow?.productTypeName || '类型详情'"
:icon="Grid"
:code="typeViewRow?.productTypeCode"
code-label="类型编码"
:sections="typeViewSections"
/>
<el-tabs v-model="activeTab" class="panel-pill-tabs panel-pill-tabs--lg">
<el-tab-pane label="细胞产品价格" name="price">
<CellProductPriceTab ref="priceTabRef" />
</el-tab-pane>
<el-tab-pane label="细胞产品类型" name="type" lazy>
<CellProductTypeTab @changed="onTypeChanged" />
</el-tab-pane>
<el-tab-pane label="细胞产品规格" name="spec" lazy>
<CellProductSpecTab />
</el-tab-pane>
<el-tab-pane label="产品包装" name="package" lazy>
<CellProductPackageTab />
</el-tab-pane>
</el-tabs>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Close, Check, View, Edit, Delete, Grid } from '@element-plus/icons-vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import {
fetchCellProductTypeList,
fetchCreateCellProductType,
fetchUpdateCellProductType,
fetchDeleteCellProductType
} from '@/api/admin'
import CellProductSpecTab from './CellProductSpecTab.vue'
import { ref, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import CellProductPriceTab from './CellProductPriceTab.vue'
import CellProductTypeTab from './CellProductTypeTab.vue'
import CellProductSpecTab from './CellProductSpecTab.vue'
import CellProductPackageTab from './CellProductPackageTab.vue'
defineOptions({ name: 'PilotCellProductType' })
const KANBAN_PAGE_SIZE = 100
const TAB_KEYS = ['price', 'type', 'spec', 'package'] as const
type TabKey = (typeof TAB_KEYS)[number]
const specTabRef = ref<InstanceType<typeof CellProductSpecTab> | null>(null)
const route = useRoute()
const router = useRouter()
function normalizeTab(raw: unknown): TabKey {
const v = Array.isArray(raw) ? raw[0] : raw
if (v === 'type' || v === 'spec' || v === 'price' || v === 'package') return v
return 'price'
}
const activeTab = ref<TabKey>(normalizeTab(route.query.tab))
const priceTabRef = ref<InstanceType<typeof CellProductPriceTab> | null>(null)
const specTotal = ref(0)
const priceTotal = ref(0)
function syncTabToQuery(tab: TabKey) {
if (route.query.tab === tab) return
router.replace({
query: {
...route.query,
tab
}
})
}
const typeSearchForm = reactive<{ keyword: string }>({ keyword: '' })
const typeTableData = ref<Api.CellProductType.CellProductTypeItem[]>([])
const typeLoading = ref(false)
const typeTotal = ref(0)
const typeDialogVisible = ref(false)
const typeDialogTitle = ref('新增类型')
const typeIsEdit = ref(false)
const typeSubmitLoading = ref(false)
const typeFormRef = ref()
const typeEditingId = ref<string | null>(null)
const typeViewVisible = ref(false)
const typeViewRow = ref<Api.CellProductType.CellProductTypeItem | null>(null)
function onTypeChanged() {
priceTabRef.value?.loadData?.()
}
const typeFormData = reactive({
productTypeCode: '',
productTypeName: '',
remark: ''
watch(activeTab, (tab) => {
syncTabToQuery(tab)
})
const typeFormRules = {
productTypeCode: [{ required: true, message: '请输入类型编码', trigger: 'blur' }],
productTypeName: [{ required: true, message: '请输入类型名称', trigger: 'blur' }]
}
const typeViewSections = computed(() => {
if (!typeViewRow.value) return []
const row = typeViewRow.value
return [
{
title: '基本信息',
fields: [
{ label: '类型编码', value: row.productTypeCode },
{ label: '类型名称', value: row.productTypeName },
{ label: '备注', value: row.remark, span: true }
]
}
]
})
function onSpecMetaChange(payload: { total: number }) {
specTotal.value = payload.total
}
function onPriceMetaChange(payload: { total: number }) {
priceTotal.value = payload.total
}
async function loadTypeData() {
typeLoading.value = true
try {
const res = await fetchCellProductTypeList({
page: 1,
pageSize: KANBAN_PAGE_SIZE,
keyword: typeSearchForm.keyword || undefined
})
typeTableData.value = res.list || []
typeTotal.value = res.total ?? 0
} finally {
typeLoading.value = false
watch(
() => route.query.tab,
(tab) => {
const next = normalizeTab(tab)
if (activeTab.value !== next) activeTab.value = next
}
}
function handleTypeSearch() {
loadTypeData()
}
function resetTypeForm() {
typeFormData.productTypeCode = ''
typeFormData.productTypeName = ''
typeFormData.remark = ''
typeEditingId.value = null
}
function handleTypeAdd() {
typeIsEdit.value = false
typeDialogTitle.value = '新增类型'
resetTypeForm()
typeDialogVisible.value = true
typeFormRef.value?.clearValidate?.()
}
function handleTypeEdit(row: Api.CellProductType.CellProductTypeItem) {
typeIsEdit.value = true
typeDialogTitle.value = '编辑类型'
typeEditingId.value = row.id
typeFormData.productTypeCode = row.productTypeCode || ''
typeFormData.productTypeName = row.productTypeName || ''
typeFormData.remark = row.remark || ''
typeDialogVisible.value = true
typeFormRef.value?.clearValidate?.()
}
async function handleTypeSubmit() {
await typeFormRef.value?.validate?.()
typeSubmitLoading.value = true
try {
const payload = {
productTypeCode: typeFormData.productTypeCode.trim(),
productTypeName: typeFormData.productTypeName.trim(),
remark: typeFormData.remark?.trim() || undefined
}
if (typeIsEdit.value && typeEditingId.value) {
await fetchUpdateCellProductType(typeEditingId.value, payload)
} else {
await fetchCreateCellProductType(payload)
}
ElMessage.success(typeIsEdit.value ? '更新成功' : '创建成功')
typeDialogVisible.value = false
await loadTypeData()
priceTabRef.value?.loadData?.()
} catch {
/* 校验或接口错误已由全局处理 */
} finally {
typeSubmitLoading.value = false
}
}
function handleTypeView(row: Api.CellProductType.CellProductTypeItem) {
typeViewRow.value = row
typeViewVisible.value = true
}
async function handleTypeDelete(row: Api.CellProductType.CellProductTypeItem) {
try {
await ElMessageBox.confirm('确定要删除该类型吗?', '提示', { type: 'warning' })
} catch {
return
}
await withFullscreenLoading(async () => {
await fetchDeleteCellProductType(row.id)
ElMessage.success('删除成功')
await loadTypeData()
priceTabRef.value?.loadData?.()
}, fullscreenLoadingText.delete)
}
watchDebounced(
() => typeSearchForm.keyword,
() => {
loadTypeData()
},
{ debounce: 300 }
)
onMounted(() => {
loadTypeData()
const next = normalizeTab(route.query.tab)
activeTab.value = next
syncTabToQuery(next)
})
</script>
<style scoped lang="scss">
.cell-product-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
padding: 16px 16px 0;
}
.stat-card {
display: flex;
align-items: center;
gap: 12px;
min-height: 72px;
padding: 12px 16px;
border: 1px solid #d1fae5;
border-radius: 10px;
background: linear-gradient(180deg, var(--primary-bg, #ecfdf5) 0%, #fff 100%);
}
.stat-icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
img {
width: 24px;
height: 24px;
object-fit: contain;
display: block;
}
}
.stat-main {
display: flex;
flex: 1;
align-items: center;
justify-content: space-between;
gap: 12px;
min-width: 0;
}
.stat-k {
font-size: 14px;
font-weight: 600;
color: var(--mint-text-muted, #334155);
}
.stat-v {
font-size: 22px;
font-weight: 700;
color: var(--primary-dark, #059669);
line-height: 1.1;
}
.kanban {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
padding: 16px;
align-items: stretch;
}
.kanban-col {
min-width: 0;
background: var(--table-head, #f8fafc);
border: 1px solid var(--mint-border, #d9e8e2);
border-radius: 10px;
display: flex;
flex-direction: column;
min-height: calc(100vh - 280px);
max-height: calc(100vh - 200px);
}
.kanban-col-hd {
padding: 10px 12px;
border-bottom: 1px solid var(--mint-border, #d9e8e2);
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-shrink: 0;
}
.kanban-col-title {
font-size: 13px;
font-weight: 600;
color: var(--mint-text-muted, #334155);
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.kanban-count {
font-size: 11px;
font-weight: 700;
padding: 2px 8px;
border-radius: 999px;
background: var(--surface, #fff);
color: var(--mint-text-muted, #64748b);
border: 1px solid var(--mint-border, #d9e8e2);
}
.kanban-add-btn {
flex-shrink: 0;
height: 32px;
padding: 0 10px;
font-size: 13px;
}
.kanban-col-search {
padding: 8px 10px 0;
flex-shrink: 0;
}
.kanban-list {
padding: 10px;
overflow-y: auto;
flex: 1;
min-height: 0;
}
@import './cell-product-kanban.scss';
.type-dialog-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
@media (max-width: 1100px) {
.kanban {
grid-template-columns: 1fr;
}
.kanban-col {
min-height: 320px;
max-height: 420px;
}
.cell-product-stats {
grid-template-columns: 1fr;
}
}
</style>
@@ -528,23 +528,29 @@
</p>
</div>
</div>
<div v-if="!isEdit" class="cell-product-toolbar">
<div class="cell-product-toolbar">
<div class="section-subtitle">细胞产品</div>
<el-button type="primary" :disabled="!canAddCellProduct" @click="addSaleProductLine">
<el-button
v-if="!isEdit"
type="primary"
:disabled="!canAddCellProduct"
@click="addSaleProductLine"
>
<el-icon><Plus /></el-icon>
添加产品
</el-button>
</div>
<el-table
v-if="!isEdit"
:data="saleProductLines"
stripe
class="cell-product-table"
style="width: 100%"
empty-text="暂无交付计划"
>
<el-table-column label="细胞产品" min-width="180">
<template #default="{ row, $index }">
<el-select
v-if="!isEdit"
v-model="row.priceId"
filterable
clearable
@@ -559,28 +565,55 @@
:value="p.priceId"
/>
</el-select>
<span v-else>{{
formatSaleProductDisplay(row) || '—'
}}</span>
</template>
</el-table-column>
<el-table-column label="规格" min-width="100">
<template #default="{ row }">{{ row.specText || '—' }}</template>
</el-table-column>
<el-table-column label="产品包装" min-width="140">
<template #default="{ row }">
<el-select
v-if="!isEdit"
v-model="row.packageId"
filterable
clearable
placeholder="选择包装"
class="cell-product-table__control"
@change="onSalePackageSelect(row, $event)"
>
<el-option
v-for="pkg in packageOptions"
:key="pkg.id"
:label="pkg.packageName"
:value="pkg.id"
/>
</el-select>
<span v-else>{{ row.packageText || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="参考单价(元)" min-width="120" align="right">
<template #default="{ row }">{{ formatMoneyDisplay(row.agreementPrice, 2) }}</template>
</el-table-column>
<el-table-column label="实际单价(元)" min-width="130">
<template #default="{ row }">
<DecimalInput
v-if="!isEdit"
v-model="row.actualPrice"
:min="0"
:precision="2"
style="width: 100%"
@change="syncSaleLineAmount(row)"
/>
<span v-else>{{ formatMoneyDisplay(row.actualPrice, 2) }}</span>
</template>
</el-table-column>
<el-table-column label="份数" min-width="90">
<template #default="{ row }">
<el-input-number
v-if="!isEdit"
v-model="row.quantity"
:min="1"
:max="9999"
@@ -588,30 +621,35 @@
style="width: 100%"
@change="syncSaleLineAmount(row)"
/>
<span v-else>{{ row.quantity ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column :label="bizConfig.pickupTimeLabel" min-width="180">
<template #default="{ row }">
<el-date-picker
v-if="!isEdit"
v-model="row.pickupTime"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择交付时间"
style="width: 100%"
/>
<span v-else>{{ row.pickupTime || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="金额(元)" min-width="120">
<template #default="{ row }">
<DecimalInput
v-if="!isEdit"
v-model="row.amount"
:min="0"
:precision="2"
style="width: 100%"
/>
<span v-else>{{ formatMoneyDisplay(row.amount, 2) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" min-width="80" align="center">
<el-table-column v-if="!isEdit" label="操作" min-width="80" align="center">
<template #default="{ $index }">
<el-button link type="danger" @click="removeSaleProductLine($index)">
<el-icon><Delete /></el-icon>
@@ -1195,6 +1233,7 @@
fetchChannelAgreements,
fetchChannelAgreementPrices,
fetchCellProductPriceList,
fetchCellProductPackageList,
fetchDictDataOptions,
fetchCustomerList,
fetchCreateCustomer
@@ -1290,6 +1329,8 @@
projectName: string
cellTypeName: string
specText: string
packageId: string
packageText: string
agreementPrice: number
actualPrice: number
quantity: number
@@ -1299,6 +1340,7 @@
let saleLineSeq = 0
const saleProductLines = ref<SaleProductLine[]>([])
const packageOptions = ref<Api.CellProductPackage.CellProductPackageItem[]>([])
const formRules = reactive({
applicationName: [{ required: true, message: '请输入申请单名称', trigger: 'blur' }],
@@ -1538,6 +1580,21 @@
return !!selectedServiceOrder.value && agreementPriceOptions.value.length > 0
})
const loadPackageOptions = async () => {
try {
const res: any = await fetchCellProductPackageList({ page: 1, pageSize: 100 })
packageOptions.value = res?.list || []
} catch {
packageOptions.value = []
}
}
const onSalePackageSelect = (line: SaleProductLine, packageId: string) => {
const pkg = packageOptions.value.find((p) => p.id === packageId)
line.packageId = packageId || ''
line.packageText = pkg?.packageName || ''
}
const addSaleProductLine = () => {
saleProductLines.value.push({
_localId: ++saleLineSeq,
@@ -1546,6 +1603,8 @@
projectName: '',
cellTypeName: '',
specText: '',
packageId: '',
packageText: '',
agreementPrice: 0,
actualPrice: 0,
quantity: 1,
@@ -2677,6 +2736,13 @@
return `${name}(${cellType}、${spec}、¥${price})`
}
const formatSaleProductDisplay = (row: SaleProductLine) => {
const name = row.projectName || row.projectCode || ''
const cellType = row.cellTypeName || ''
if (name && cellType) return `${name}(${cellType})`
return name || cellType
}
// ── 弹窗打开 ───────────────────────────────────────────────────────────
const handleOpen = () => {
resetForm()
@@ -2686,6 +2752,9 @@
if (isClinical.value || isBasicExperiment.value) {
loadClinicalCellProductPrices()
}
if (isBasicExperiment.value) {
loadPackageOptions()
}
if (isBasicExperiment.value && !isEdit.value) {
addSaleProductLine()
}
@@ -2878,6 +2947,8 @@
projectName: cp.projectName || '',
cellTypeName: cp.cellTypeName || s.cellType || '',
specText: cp.specText || s.specText || '',
packageId: cp.packageId || s.packageId || '',
packageText: cp.packageText || s.packageText || '',
agreementPrice: cp.agreementPrice ?? 0,
actualPrice: cp.actualPrice ?? 0,
quantity: cp.quantity || 1,
@@ -2922,6 +2993,10 @@
ElMessage.warning(`第 ${i + 1} 行请选择细胞产品`)
return
}
if (!line.packageId) {
ElMessage.warning(`第 ${i + 1} 行请选择产品包装`)
return
}
if (!line.pickupTime) {
ElMessage.warning(`第 ${i + 1} 行请填写提货时间`)
return
@@ -3030,6 +3105,8 @@
projectName: line.projectName,
cellTypeName: line.cellTypeName,
specText: line.specText,
packageId: line.packageId,
packageText: line.packageText,
agreementPrice: line.agreementPrice,
actualPrice: line.actualPrice,
quantity: line.quantity || 1,
@@ -234,7 +234,10 @@
: '质检工单派发成功'
)
emit('success', { sampleCode: props.workOrderRow.sampleCode })
handleClose()
resetForm()
if (productionTasks.value.length === 1) {
form.taskId = productionTasks.value[0].taskId
}
} finally {
submitting.value = false
}
@@ -72,16 +72,16 @@
>
<el-icon><View /></el-icon>查看
</el-button>
<el-button
v-if="canSaveTaskSet(row)"
class="btn-op-item"
@click="emit('command', 'save-set', row)"
>
<el-icon><FolderAdd /></el-icon>保存为模版
</el-button>
<el-button class="btn-op-item" @click="emit('view-qc', row)">
<el-icon><DataLine /></el-icon>查看质检数据
</el-button>
<el-button
v-if="showStorageCertQcButton(row)"
class="btn-op-item"
@click="openStorageCertQcDialog(row)"
>
<el-icon><Document /></el-icon>填写储存证质检信息
</el-button>
<el-tooltip
v-if="!isMineMode && rowCardMeta(row).allowDispatchQc"
:content="dispatchQcTooltip(row)"
@@ -112,32 +112,42 @@
>
<el-icon><Check /></el-icon>完成工单
</el-button>
<el-button
v-if="showCardComplete(row) && canVoidWorkOrder(row)"
class="btn-op-item"
type="danger"
plain
@click="emit('void', row)"
>
<el-icon><Close /></el-icon>作废工单
</el-button>
<el-dropdown
v-if="!isMineMode && hasMoreActions(row)"
v-if="hasMoreActions(row)"
trigger="hover"
@command="(cmd) => emit('command', cmd, row)"
@command="(cmd) => handleMoreCommand(cmd, row)"
>
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="canDispatchTask(row)" command="view">
<el-dropdown-item
v-if="!isMineMode && canDispatchTask(row)"
command="view"
>
<el-icon><View /></el-icon>查看
</el-dropdown-item>
<el-dropdown-item v-if="canEditWorkOrder(row)" command="edit">
<el-dropdown-item
v-if="!isMineMode && canEditWorkOrder(row)"
command="edit"
>
<el-icon><EditPen /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item v-if="canDeleteWorkOrder(row)" command="delete" divided>
<el-dropdown-item v-if="canSaveTaskSet(row)" command="save-set">
<el-icon><FolderAdd /></el-icon>保存为模版
</el-dropdown-item>
<el-dropdown-item
v-if="showCardComplete(row) && canVoidWorkOrder(row)"
command="void"
>
<el-icon><Close /></el-icon>作废工单
</el-dropdown-item>
<el-dropdown-item
v-if="!isMineMode && canDeleteWorkOrder(row)"
command="delete"
divided
>
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
@@ -201,6 +211,10 @@
<dt>规格</dt>
<dd>{{ product.specText || row.specText || '—' }}</dd>
</div>
<div class="production-work-order-card__cell-field">
<dt>产品包装</dt>
<dd>{{ product.packageText || row.packageText || '—' }}</dd>
</div>
<div class="production-work-order-card__cell-field">
<dt>数量</dt>
<dd>{{ product.quantity != null ? product.quantity : '—' }}</dd>
@@ -222,10 +236,12 @@
</div>
</div>
<p v-else class="production-work-order-card__cells-fallback">
<template v-if="row.cellType || row.specText">
<template v-if="row.cellType || row.specText || row.packageText">
<span v-if="row.cellType">细胞类型:{{ row.cellType }}</span>
<span v-if="row.cellType && row.specText"> · </span>
<span v-if="row.specText">规格:{{ row.specText }}</span>
<span v-if="(row.cellType || row.specText) && row.packageText"> · </span>
<span v-if="row.packageText">包装:{{ row.packageText }}</span>
</template>
<template v-else>暂无细胞产品明细</template>
</p>
@@ -297,6 +313,12 @@
@current-change="(v) => emit('page-change', v)"
/>
</div>
<StorageCertQcDialog
v-model:visible="storageCertQcVisible"
mode="edit"
:sample-code="storageCertQcSampleCode"
:sample-type-name="storageCertQcSampleTypeName"
/>
</div>
</template>
@@ -320,6 +342,8 @@
import type { OutboundQcDetailProduct } from '@/api/qc-work-order'
import type { ProductionWorkOrderCardMetaConfig } from '@/utils/production-work-order-card-meta'
import SampleCodeWithSpecialDisplay from '@/components/business/sample-code-with-special-display/index.vue'
import StorageCertQcDialog from '@/components/business/storage-cert-qc-dialog/index.vue'
import { supportsStorageCertQc } from '@/utils/storage-cert-qc-fields'
import {
getCardMetaConfigForLegacyTab,
resolveCardMetaForRow
@@ -384,6 +408,26 @@
}
const expandedFeeIds = ref<Set<string>>(new Set())
const storageCertQcVisible = ref(false)
const storageCertQcSampleCode = ref('')
const storageCertQcSampleTypeName = ref('')
function showStorageCertQcButton(row: ProductionWorkOrderItem) {
const tabKey = resolveEffectiveRowTabKey(listTabKey.value, row)
if (tabKey !== 'inbound_cell_storage') return false
return supportsStorageCertQc(String(row.sampleTypeName || ''))
}
function openStorageCertQcDialog(row: ProductionWorkOrderItem) {
const code = String(row.sampleCode || '').trim()
if (!code) {
ElMessage.warning('缺少样本编码,无法填写')
return
}
storageCertQcSampleCode.value = code
storageCertQcSampleTypeName.value = String(row.sampleTypeName || '')
storageCertQcVisible.value = true
}
function isFeeExpanded(id: string) {
return expandedFeeIds.value.has(id)
@@ -573,7 +617,25 @@
}
function hasMoreActions(row: ProductionWorkOrderItem) {
return canDispatchTask(row) || canEditWorkOrder(row) || canDeleteWorkOrder(row) || true
if (isMineMode.value) {
return showCardComplete(row) && canVoidWorkOrder(row)
}
return (
canDispatchTask(row) ||
canEditWorkOrder(row) ||
canSaveTaskSet(row) ||
(showCardComplete(row) && canVoidWorkOrder(row)) ||
canDeleteWorkOrder(row) ||
true
)
}
function handleMoreCommand(cmd: string, row: ProductionWorkOrderItem) {
if (cmd === 'void') {
emit('void', row)
return
}
emit('command', cmd, row)
}
</script>
@@ -186,7 +186,7 @@
<!-- 搜索框 -->
<el-input
v-model="searchKeyword"
placeholder="搜索产品编码或名称"
placeholder="搜索产品编码、名称或储物格位置"
clearable
style="margin-bottom: 12px"
@input="handleSearch"
@@ -402,6 +402,7 @@
(cp) =>
(cp.productCode || '').toLowerCase().includes(keyword) ||
(cp.productName || '').toLowerCase().includes(keyword) ||
(cp.storageSlotName || '').toLowerCase().includes(keyword) ||
(cp.label || '').toLowerCase().includes(keyword)
)
})
@@ -44,16 +44,6 @@
}}</span>
<div class="work-order-task-step-sublist__ops">
<el-button
v-if="canEnterStep(row)"
class="btn-op-item primary"
type="primary"
size="small"
@click.stop="handleEnterStep(row)"
>
<el-icon><VideoPlay /></el-icon>进入执行
</el-button>
<el-button
v-else
class="btn-op-item primary"
type="primary"
size="small"
@@ -85,7 +75,7 @@
</template>
<script setup lang="ts">
import { VideoPlay, View } from '@element-plus/icons-vue'
import { View } from '@element-plus/icons-vue'
import {
fetchProductionTaskDetail,
type ProductionTaskOperateScope
@@ -128,7 +118,7 @@
const tableData = ref<Record<string, any>[]>([])
const loading = ref(false)
/** 我的生产任务:展示任务下全部工艺步骤;本人负责步骤高亮,进入执行仍仅限本人步骤 */
/** 我的生产任务:展示任务下全部工艺步骤;本人负责步骤高亮 */
const visibleSteps = computed(() => tableData.value)
function assigneeDisplayName(raw: string): string {
@@ -176,12 +166,6 @@
return (row.assigneeUserIds || []).includes(uid)
}
function canEnterStep(row: Record<string, any>) {
if (row.stepStatus === 'ywc') return false
if (isMineMode.value) return isStepAssignee(row)
return true
}
async function loadData() {
const taskId = props.task?.id
if (!taskId) {
@@ -224,10 +208,6 @@
executionDialogVisible.value = true
}
function handleEnterStep(row: Record<string, any>) {
openExecution(row, false)
}
function handleViewStep(row: Record<string, any>) {
openExecution(row, true)
}
@@ -65,6 +65,14 @@
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="packageText"
label="产品包装"
min-width="120"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="quantity"
label="数量"
@@ -101,6 +101,10 @@
<dt>规格</dt>
<dd>{{ product.specText || row.specText || '—' }}</dd>
</div>
<div class="qc-work-order-card__cell-field">
<dt>产品包装</dt>
<dd>{{ product.packageText || row.packageText || '—' }}</dd>
</div>
<div class="qc-work-order-card__cell-field">
<dt>数量</dt>
<dd>{{ product.quantity != null ? product.quantity : '—' }}</dd>
@@ -122,10 +126,12 @@
</div>
</div>
<p v-else class="qc-work-order-card__cells-fallback">
<template v-if="row.cellType || row.specText">
<template v-if="row.cellType || row.specText || row.packageText">
<span v-if="row.cellType">细胞类型:{{ row.cellType }}</span>
<span v-if="row.cellType && row.specText"> · </span>
<span v-if="row.specText">规格:{{ row.specText }}</span>
<span v-if="(row.cellType || row.specText) && row.packageText"> · </span>
<span v-if="row.packageText">包装:{{ row.packageText }}</span>
</template>
<template v-else>暂无细胞产品明细</template>
</p>
@@ -339,9 +345,12 @@
const sampleSpecialDialogVisible = ref(false)
const storageCertQcVisible = ref(false)
const showStorageCertQcButton = computed(() =>
supportsStorageCertQc(String(props.row?.sampleTypeName || ''))
)
const showStorageCertQcButton = computed(() => {
// 科研捐赠质检不填写储存证质检信息(质检工单 / 我的质检任务共用本卡片)
const tabKey = resolveEffectiveRowTabKey(props.cardMetaConfig.tabKey, props.row)
if (tabKey === 'inbound_research_donation') return false
return supportsStorageCertQc(String(props.row?.sampleTypeName || ''))
})
const sampleSpecialMarkTarget = computed(() =>
resolveInboundSampleSpecialMarkTargetFromRow(props.row)
@@ -7,6 +7,7 @@ import {
groupMineTasksByWorkOrder,
filterMineEntriesByWorkOrderSearch,
sortMineEntriesForInboundLabStorage,
sortMineEntriesForTemporary,
paginateMineEntries,
buildMineTableRows
} from '@/utils/qc-mine-task-groups'
@@ -23,6 +24,10 @@ import {
} from '@/utils/qc-work-order-tabs'
import { toAllTabListQuery } from '@/utils/qc-work-order-category'
import { getCardMetaConfig } from '@/utils/qc-work-order-card-meta'
import {
DEFAULT_DONATION_PROTOCOL_STATUS_FILTER,
joinProtocolStatusFilter
} from '@/utils/donation-protocol-status-filter'
import { joinWorkOrderStatusFilter } from '@/utils/status-tag-registry'
import { fetchAllPagedRecords } from '@/utils/fetch-paged-list'
import { fetchQcInspectionBatchesBatch, type QcInspectionBatch } from '@/utils/qc-inspection-batch'
@@ -52,6 +57,7 @@ export function useQcWorkOrderTabPage(
qcCategory: '',
keyword: '',
workOrderStatus: [...DEFAULT_WORK_ORDER_STATUS_FILTER],
protocolStatus: [...DEFAULT_DONATION_PROTOCOL_STATUS_FILTER],
triggerSource: '',
sampleTypeId: '',
taskStatus: '',
@@ -122,6 +128,10 @@ export function useQcWorkOrderTabPage(
workOrderStatus: searchForm.workOrderStatus.length
? [...searchForm.workOrderStatus]
: undefined,
protocolStatus:
tabKey.value === 'inbound_research_donation'
? [...searchForm.protocolStatus]
: undefined,
qcCategory: searchForm.qcCategory || undefined,
triggerSource:
shouldApplyTriggerSourceFilter(tabKey.value, tab.listLayout) && searchForm.triggerSource
@@ -148,6 +158,8 @@ export function useQcWorkOrderTabPage(
entries = filterMineEntriesByWorkOrderSearch(entries, buildMineWorkOrderSearchFilters())
if (isInboundSampleQcTab(tabKey.value)) {
entries = sortMineEntriesForInboundLabStorage(entries)
} else if (tabKey.value === 'temporary') {
entries = sortMineEntriesForTemporary(entries)
}
const pageResult = paginateMineEntries(entries, pagination.page, pagination.pageSize)
const tableModel = buildMineTableRows(pageResult.items)
@@ -159,6 +171,12 @@ export function useQcWorkOrderTabPage(
}
const tab = tabConfig.value
if (tabKey.value === 'inbound_research_donation' && !searchForm.protocolStatus.length) {
tableData.value = []
presetBatchesByWorkOrderId.value = undefined
pagination.total = 0
return
}
const timeParams =
isAllQcWorkOrderTab(tabKey.value) || !tabUsesInfusionTimeFilter(tab)
? {
@@ -177,6 +195,10 @@ export function useQcWorkOrderTabPage(
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined,
workOrderStatus: joinWorkOrderStatusFilter(searchForm.workOrderStatus),
protocolStatus:
tabKey.value === 'inbound_research_donation'
? joinProtocolStatusFilter(searchForm.protocolStatus)
: undefined,
triggerSource:
shouldApplyTriggerSourceFilter(tabKey.value, tab.listLayout) && searchForm.triggerSource
? searchForm.triggerSource
@@ -211,6 +233,7 @@ export function useQcWorkOrderTabPage(
searchForm.qcCategory = ''
searchForm.keyword = ''
searchForm.workOrderStatus = [...DEFAULT_WORK_ORDER_STATUS_FILTER]
searchForm.protocolStatus = [...DEFAULT_DONATION_PROTOCOL_STATUS_FILTER]
searchForm.triggerSource = ''
searchForm.sampleTypeId = ''
searchForm.taskStatus = ''
@@ -40,6 +40,22 @@
/>
</el-select>
</el-form-item>
<el-form-item v-if="tabKey === 'inbound_research_donation'" label="协议状态">
<el-select
v-model="searchForm.protocolStatus"
placeholder="全部"
clearable
multiple
style="width: 100%"
>
<el-option
v-for="opt in DONATION_PROTOCOL_STATUS_FILTER_OPTIONS"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item
v-if="!isAllTab && (tabConfig.listLayout !== 'standalone' || tabKey === 'temporary')"
label="工单来源"
@@ -170,6 +186,22 @@
/>
</el-select>
</el-form-item>
<el-form-item v-if="tabKey === 'inbound_research_donation'" label="协议状态">
<el-select
v-model="searchForm.protocolStatus"
placeholder="全部"
clearable
multiple
style="width: 100%"
>
<el-option
v-for="opt in DONATION_PROTOCOL_STATUS_FILTER_OPTIONS"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
<el-form-item label="任务状态">
<el-select
v-model="searchForm.taskStatus"
@@ -359,6 +391,7 @@
QC_WORK_ORDER_CATEGORY_OPTIONS,
QC_ALL_TAB_TRIGGER_SOURCE_OPTIONS
} from '@/utils/qc-work-order-category'
import { DONATION_PROTOCOL_STATUS_FILTER_OPTIONS } from '@/utils/donation-protocol-status-filter'
import { QC_WORK_ORDER_STATUS_FILTER_OPTIONS } from '@/utils/status-tag-registry'
import QcWorkOrderCardList from '../QcWorkOrderCardList.vue'
import { QC_WORK_ORDER_VIEW_DIALOG_COMPONENTS } from '../view-dialogs/view-dialog-registry'
@@ -40,6 +40,14 @@
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="packageText"
label="产品包装"
min-width="120"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
prop="quantity"
label="数量"