fix: 协议子资源单事务保存,并补齐样本编码/OA/质检等近期能力
锁定缴费校验失败时整单回滚,避免样本半成功落库;前端禁止删除锁定缴费样本,改码走更正编码。同步落地样本更正、采集微信通知、OA提及未读、细胞产品套餐与相关迁移文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
19bc66ba45
commit
5dad1bb0e3
142 files changed
+95584
-65717
No files matched your search
@@ -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}` })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 中试服务管理 — 细胞产品价格
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>>({})
|
||||
|
||||
@@ -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()
|
||||
|
||||
Vendored
+13
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+85
-23
@@ -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)
|
||||
}
|
||||
|
||||
+8
@@ -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)
|
||||
|
||||
+23
@@ -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'
|
||||
|
||||
+8
@@ -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="数量"
|
||||
|
||||
Reference in new issue
Block a user