feat(project): group assignment records by business type

Add four business tabs and identifying fields so users can locate orders before assigning projects. Introduce a permission-scoped detail query with child EXISTS searches and batched enrichment, preserving statistics and assignment writes.

Approved Figma v2; independent source review completed. Tests authored but not executed under the user self-test arrangement. Related to issue #13.
This commit is contained in:
zlwu committed 2026-11-04 14:14:50 +08:00
1 parent 2a7e763cdf
commit 29f9866a24
9 files changed
+611 -22

No files matched your search

+32
View File
@@ -21,6 +21,33 @@ export type ProjectOption = {
disabled?: boolean
}
export type ProjectRecord = Record<string, any>
export type AssignmentBizType = Exclude<BizType, 'presign'>
export interface AssignmentRecord {
id: string
bizType: AssignmentBizType
bizNo: string | null
status: string
projectId: string | null
projectName: string | null
teamId: string | null
teamName: string | null
createTime: string | null
motherName?: string
motherPhone?: string
hospitalName?: string
salesUserName?: string
collectionDate?: string
donationDate?: string
applicationName?: string | null
channelName?: string | null
serviceOrderName?: string | null
patientName?: string
patientPhone?: string
projectTitle?: string | null
customerName?: string | null
customerContact?: string | null
customerPhone?: string | null
}
const base = '/web/admin/project-management'
export const fetchProjectOptions = (params: {
bizType?: BizType
@@ -58,6 +85,11 @@ export const fetchProjectRecords = (params: Record<string, unknown>) =>
url: `${base}/records`,
params
})
export const fetchAssignmentRecords = (params: Record<string, unknown>) =>
request.get<{ list: AssignmentRecord[]; total: number; statusCounts: Record<string, number> }>({
url: `${base}/assignments/records`,
params
})
export const previewAssignment = (data: ProjectRecord) =>
request.post<{ records: ProjectRecord[] }>({ url: `${base}/assignments/preview`, data })
export const saveAssignment = (data: ProjectRecord) =>
@@ -1,10 +1,29 @@
<template>
<div class="page-container">
<div v-if="assignment" class="panel-pill-tabs panel-pill-tabs--lg">
<div class="panel-pill-tabs__track" role="tablist" aria-label="业务类型">
<button
v-for="item in assignmentTabs"
:key="item.key"
type="button"
role="tab"
class="panel-pill-tabs__btn"
:class="{ 'is-active': query.bizType === item.key }"
:aria-selected="query.bizType === item.key"
@click="switchBusiness(item.key)"
>
{{ item.title }}
</button>
</div>
</div>
<div class="filter-block">
<el-form class="filter-form" label-position="top" @submit.prevent="search"
><div class="filter-grid">
<el-form-item label="业务类型"
><el-select v-model="query.bizType" @change="search"
<el-form-item label="关键词">
<el-input v-model="query.keyword" :placeholder="keywordHint" clearable />
</el-form-item>
<el-form-item v-if="!assignment" label="业务类型"
><el-select v-model="query.bizType" @change="resetBusinessFilters"
><el-option
v-for="(label, key) in bizLabels"
:key="key"
@@ -12,12 +31,20 @@
:label="label" /></el-select
></el-form-item>
<el-form-item label="项目组"
><ProjectSelect filter team v-model="query.teamId" :biz-type="query.bizType"
><ProjectSelect
:key="`team-${query.bizType}`"
filter
team
v-model="query.teamId"
:biz-type="query.bizType"
/></el-form-item>
<el-form-item label="项目"
><ProjectSelect filter v-model="query.projectId" :biz-type="query.bizType"
><ProjectSelect
:key="`project-${query.bizType}`"
filter
v-model="query.projectId"
:biz-type="query.bizType"
/></el-form-item>
<el-form-item label="关键词"><el-input v-model="query.keyword" clearable /></el-form-item>
<el-form-item label="当前状态"
><el-select v-model="query.status" clearable
><el-option
@@ -46,8 +73,20 @@
></el-form
>
</div>
<el-alert v-if="loadError" type="error" :closable="false" title="列表加载失败,请重试">
<el-button link type="primary" @click="load">
<el-icon><RefreshRight /></el-icon>重试
</el-button>
</el-alert>
<div class="list-body list-wrap"
><el-table :data="rows" v-loading="loading" class="list-table" stripe>
><el-table
:key="query.bizType"
:data="rows"
v-loading="loading"
class="list-table"
stripe
:empty-text="loading ? '正在加载…' : loadError ? '数据加载失败' : '暂无符合条件的单据'"
>
<el-table-column
type="index"
label="序号"
@@ -56,18 +95,29 @@
header-align="center"
:index="(index) => (query.page - 1) * query.pageSize + index + 1"
/>
<el-table-column prop="bizNo" label="单据编号" min-width="180" />
<el-table-column
v-for="column in visibleColumns"
:key="column.prop"
:prop="column.prop"
:label="column.label"
:min-width="column.minWidth"
show-overflow-tooltip
>
<template #default="{ row }">{{ formatDisplayOrDash(row[column.prop]) }}</template>
</el-table-column>
<el-table-column label="状态" min-width="110"
><template #default="{ row }"
><el-tag :type="statusTagType(row.status)" size="small">{{
statusLabel(row.status)
}}</el-tag></template
></el-table-column
>
<el-table-column prop="teamName" label="项目组" min-width="140" />
<el-table-column prop="projectName" label="项目" min-width="160"
<el-table-column prop="projectName" label="所属项目" min-width="160"
><template #default="{ row }">{{
row.projectName || '未归属'
}}</template></el-table-column
>
<el-table-column label="状态" min-width="110"
><template #default="{ row }"
><el-tag type="info">{{ statusLabel(row.status) }}</el-tag></template
></el-table-column
>
<el-table-column prop="createTime" label="创建时间" min-width="180">
<template #default="{ row }">{{ formatDisplayOrDash(row.createTime) }}</template>
</el-table-column>
@@ -83,11 +133,12 @@
> </el-table
><div class="pagination list-pager-bar"
><el-pagination
v-model:current-page="query.page"
:current-page="query.page"
:page-size="query.pageSize"
:disabled="loading"
:total="total"
layout="total, prev, pager, next"
@current-change="load" /></div
@current-change="changePage" /></div
></div>
<el-dialog
v-model="visible"
@@ -102,7 +153,8 @@
><el-form-item label="目标项目" required
><ProjectSelect
v-model="target"
:biz-type="query.bizType"
:key="selected.id"
:biz-type="selected.bizType"
@update:model-value="previewRows = []" /></el-form-item
><el-form-item label="调整原因" required
><el-input v-model="reason" type="textarea" :maxlength="500" /></el-form-item
@@ -175,18 +227,25 @@
protocolStatusLabel,
donationStatusLabel,
presignOrderStatusLabel,
applicationStatusLabel
applicationStatusLabel,
protocolStatusTagType,
donationStatusTagType,
presignOrderStatusTagType,
applicationStatusTagType
} from '@/utils/status-tag-registry'
import { Search, Edit, Clock, Close, View, Check } from '@element-plus/icons-vue'
import { Search, Edit, Clock, Close, View, Check, RefreshRight } from '@element-plus/icons-vue'
import { assignmentTabs, assignmentColumns, assignmentKeywordHints } from './assignment-columns'
import ProjectSelect from '@/components/business/ProjectSelect.vue'
import { assignmentSnapshot } from '@/utils/project-management'
import {
bizLabels,
fetchProjectRecords,
fetchAssignmentRecords,
previewAssignment,
saveAssignment,
fetchAssignmentHistory,
type BizType,
type AssignmentBizType,
type ProjectRecord
} from '@/api/project-management'
const props = defineProps<{ assignment?: boolean; filters?: Record<string, any> }>()
@@ -205,6 +264,7 @@
total = ref(0),
statusCounts = ref<Record<string, number>>({}),
loading = ref(false),
loadError = ref(false),
visible = ref(false),
busy = ref(false),
target = ref(''),
@@ -213,6 +273,15 @@
previewRows = ref<ProjectRecord[]>([]),
historyVisible = ref(false),
historyRows = ref<ProjectRecord[]>([])
const visibleColumns = computed(() =>
props.assignment
? assignmentColumns[query.bizType as AssignmentBizType]
: [{ prop: 'bizNo', label: '单据编号', minWidth: 180 }]
)
const keywordHint = computed(() =>
props.assignment ? assignmentKeywordHints[query.bizType as AssignmentBizType] : '请输入单据编号'
)
let loadVersion = 0
function statusLabel(status: string) {
return query.bizType === 'presign'
? presignOrderStatusLabel(status)
@@ -222,27 +291,69 @@
? donationStatusLabel(status)
: applicationStatusLabel(status)
}
function statusTagType(status: string) {
return query.bizType === 'presign'
? presignOrderStatusTagType(status)
: query.bizType === 'cell_storage'
? protocolStatusTagType(status)
: query.bizType === 'research_donation'
? donationStatusTagType(status)
: applicationStatusTagType(status)
}
async function load() {
const version = ++loadVersion
loading.value = true
loadError.value = false
rows.value = []
try {
const result = await fetchProjectRecords({
const fetchRecords = props.assignment ? fetchAssignmentRecords : fetchProjectRecords
const result = await fetchRecords({
...props.filters,
...query,
teamId: query.teamId || undefined,
startDate: dateRange.value?.[0],
endDate: dateRange.value?.[1]
})
if (version !== loadVersion) return
rows.value = result.list
total.value = result.total
statusCounts.value = result.statusCounts || {}
} catch {
if (version === loadVersion) {
loadError.value = true
total.value = 0
statusCounts.value = {}
}
} finally {
loading.value = false
if (version === loadVersion) loading.value = false
}
}
function resetBusinessFilters() {
query.page = 1
total.value = 0
query.status = ''
query.teamId = ''
query.projectId = ''
query.keyword = ''
statusCounts.value = {}
visible.value = false
historyVisible.value = false
search()
}
function switchBusiness(kind: AssignmentBizType) {
if (query.bizType === kind) return
query.bizType = kind
resetBusinessFilters()
}
function search() {
query.page = 1
load()
}
function changePage(page: number) {
if (loading.value || loadError.value || page === query.page) return
query.page = page
load()
}
function open(row: ProjectRecord) {
selected.value = row
target.value = ''
@@ -257,13 +368,16 @@
}
busy.value = true
const previewTarget = target.value
const previewRecord = selected.value
try {
const result = await previewAssignment({
bizType: selected.value.bizType,
id: selected.value.id,
projectId: target.value
})
previewRows.value = target.value === previewTarget ? result.records : []
if (selected.value === previewRecord && target.value === previewTarget && visible.value) {
previewRows.value = result.records
}
} finally {
busy.value = false
}
@@ -288,7 +402,9 @@
}
}
async function history(row: ProjectRecord) {
const version = loadVersion
const result = await fetchAssignmentHistory({ bizType: row.bizType, id: row.id })
if (version !== loadVersion) return
historyRows.value = result.list
historyVisible.value = true
}
@@ -301,11 +417,19 @@
: []
Object.assign(
query,
{ bizType: 'cell_storage', projectId: '', teamId: '', unassigned: false },
{
bizType: 'cell_storage',
projectId: '',
teamId: '',
keyword: '',
status: '',
unassigned: false
},
props.filters || {}
)
search()
},
{ immediate: true, deep: true }
)
onBeforeUnmount(() => loadVersion++)
</script>
@@ -0,0 +1,56 @@
import type { AssignmentBizType, AssignmentRecord } from '@/api/project-management'
interface AssignmentColumn {
prop: keyof AssignmentRecord
label: string
minWidth: number
}
export const assignmentTabs: { key: AssignmentBizType; title: string }[] = [
{ key: 'cell_storage', title: '细胞存储' },
{ key: 'research_donation', title: '科研捐赠' },
{ key: 'pilot_service', title: '中试服务' },
{ key: 'basic_experiment', title: '基础实验' }
]
const collectionColumns: AssignmentColumn[] = [
{ prop: 'motherName', label: '母亲姓名', minWidth: 120 },
{ prop: 'motherPhone', label: '母亲电话', minWidth: 150 },
{ prop: 'hospitalName', label: '采集医院', minWidth: 180 },
{ prop: 'salesUserName', label: '市场专员', minWidth: 120 }
]
export const assignmentColumns: Record<AssignmentBizType, AssignmentColumn[]> = {
cell_storage: [
{ prop: 'bizNo', label: '协议单号', minWidth: 180 },
...collectionColumns,
{ prop: 'collectionDate', label: '采集日期', minWidth: 140 }
],
research_donation: [
{ prop: 'bizNo', label: '捐赠单号', minWidth: 180 },
...collectionColumns,
{ prop: 'donationDate', label: '捐赠日期', minWidth: 140 }
],
pilot_service: [
{ prop: 'bizNo', label: '申请单号', minWidth: 180 },
{ prop: 'applicationName', label: '申请名称', minWidth: 180 },
{ prop: 'channelName', label: '渠道名称', minWidth: 160 },
{ prop: 'serviceOrderName', label: '服务订单名称', minWidth: 180 },
{ prop: 'patientName', label: '患者姓名', minWidth: 120 },
{ prop: 'patientPhone', label: '患者电话', minWidth: 150 }
],
basic_experiment: [
{ prop: 'bizNo', label: '申请单号', minWidth: 180 },
{ prop: 'projectTitle', label: '课题名称', minWidth: 200 },
{ prop: 'customerName', label: '客户名称', minWidth: 180 },
{ prop: 'customerContact', label: '客户联系人', minWidth: 140 },
{ prop: 'customerPhone', label: '联系电话', minWidth: 150 }
]
}
export const assignmentKeywordHints: Record<AssignmentBizType, string> = {
cell_storage: '协议单号 / 母亲姓名、电话 / 医院 / 市场专员',
research_donation: '捐赠单号 / 母亲姓名、电话 / 医院 / 市场专员',
pilot_service: '申请单号、名称 / 渠道 / 服务订单 / 患者姓名、电话',
basic_experiment: '申请单号 / 课题 / 客户名称、联系人、电话'
}