Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fefef41524 | ||
|
|
e97f47b06a | ||
|
|
a4defb2c94 | ||
|
|
8717702b58 | ||
|
|
d30dbad788 | ||
|
|
e92112e9fd | ||
|
|
d6dc768efb | ||
|
|
96518c08fb | ||
|
|
54a34b24ba |
No files matched your search
@@ -240,6 +240,10 @@ class Tracker:
|
||||
if not value:
|
||||
return records
|
||||
records.extend(value)
|
||||
# Gitea comment pages may ignore page and keep returning the same short
|
||||
# list; a short page is the conventional last-page signal.
|
||||
if len(value) < PAGE_SIZE:
|
||||
return records
|
||||
raise InstallError('issue tracker pagination limit exceeded')
|
||||
|
||||
def issues(self):
|
||||
|
||||
@@ -984,11 +984,14 @@ def list_protocols(
|
||||
for row in cell_rows
|
||||
)
|
||||
if source in (None, SOURCE_DONATION):
|
||||
if status or collected_from or collected_until:
|
||||
donation_enabled = False
|
||||
else:
|
||||
donation_enabled = True
|
||||
if not donation_enabled:
|
||||
# 混合列表(source=None)下状态/采集时间只覆盖细胞存储,捐赠侧跳过。
|
||||
# 科研捐赠单源列表支持 protocolStatus→donation_status;采集时间筛仍不支持捐赠。
|
||||
skip_donation = False
|
||||
if source is None and (status or collected_from or collected_until):
|
||||
skip_donation = True
|
||||
elif source == SOURCE_DONATION and (collected_from or collected_until):
|
||||
skip_donation = True
|
||||
if skip_donation:
|
||||
query = None
|
||||
else:
|
||||
query = apply_donation_scope(
|
||||
@@ -1001,6 +1004,8 @@ def list_protocols(
|
||||
if query is None:
|
||||
donation_rows = []
|
||||
else:
|
||||
if status and source == SOURCE_DONATION:
|
||||
query = query.filter(BizResearchDonation.donation_status == status)
|
||||
if created_from:
|
||||
query = query.filter(BizResearchDonation.create_time >= created_from)
|
||||
if created_until:
|
||||
|
||||
@@ -3733,6 +3733,29 @@ def test_protocol_list_filters_status_create_time_and_sample_collect_time():
|
||||
assert [item['id'] for item in items] == ['hit']
|
||||
|
||||
|
||||
def test_donation_list_filters_by_protocol_status_alias():
|
||||
from app.services.market_protocol import list_protocols
|
||||
|
||||
db = _session()
|
||||
_masters(db)
|
||||
hit = _donation(db, 'don-hit', created=datetime(2026, 10, 8, 9, 0))
|
||||
hit.donation_status = 'sxz'
|
||||
miss = _donation(db, 'don-miss', created=datetime(2026, 10, 8, 9, 0))
|
||||
miss.donation_status = 'dsx'
|
||||
db.commit()
|
||||
|
||||
items, total = list_protocols(
|
||||
db,
|
||||
ctx=_ctx('specialist'),
|
||||
user_info=_user(),
|
||||
source='research_donation',
|
||||
protocol_status='sxz',
|
||||
)
|
||||
|
||||
assert total == 1
|
||||
assert [item['id'] for item in items] == ['don-hit']
|
||||
|
||||
|
||||
def test_cell_create_rolls_back_main_customer_and_children_on_child_failure():
|
||||
from app.services.market_protocol import create_protocol
|
||||
|
||||
|
||||
@@ -28,25 +28,27 @@
|
||||
class="market-form-fields"
|
||||
:class="{ 'market-form-fields--grid-2': section.fieldsLayout === 'grid-2' }"
|
||||
>
|
||||
<MarketFormField
|
||||
v-for="field in visibleFields"
|
||||
:key="field.key"
|
||||
:field="field"
|
||||
:model-value="value?.[field.key]"
|
||||
:options="fieldOptions(field)"
|
||||
:uploading="uploading"
|
||||
:disabled="fieldDisabled(field)"
|
||||
:search-customers="searchCustomers"
|
||||
@update:model-value="setValue(field, $event)"
|
||||
@upload="$emit('upload', { section, field, file: $event })"
|
||||
@customer-select="$emit('customer-select', { section, ...$event })"
|
||||
/>
|
||||
<template v-for="item in fieldsWithInsert" :key="item.field.key">
|
||||
<slot v-if="item.insertBefore" name="insert" />
|
||||
<MarketFormField
|
||||
:field="item.field"
|
||||
:model-value="value?.[item.field.key]"
|
||||
:options="fieldOptions(item.field)"
|
||||
:uploading="uploading"
|
||||
:disabled="fieldDisabled(item.field)"
|
||||
:search-customers="searchCustomers"
|
||||
@update:model-value="setValue(item.field, $event)"
|
||||
@upload="$emit('upload', { section, field: item.field, file: $event })"
|
||||
@customer-select="$emit('customer-select', { section, ...$event })"
|
||||
/>
|
||||
</template>
|
||||
<slot v-if="appendInsert" name="insert" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed, useSlots } from 'vue'
|
||||
import MarketFormField from './MarketFormField.vue'
|
||||
import { sampleCodeOptionsForRow } from '../reference-options.js'
|
||||
|
||||
@@ -58,9 +60,12 @@ const props = defineProps({
|
||||
mode: { type: String, default: 'create' },
|
||||
uploading: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false },
|
||||
searchCustomers: { type: Function, default: null }
|
||||
searchCustomers: { type: Function, default: null },
|
||||
/** 在首个匹配可见字段前插入 #insert 插槽(如协议状态下的项目归属) */
|
||||
insertBeforeKeys: { type: Array, default: () => [] }
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'upload', 'field-change', 'customer-select'])
|
||||
const slots = useSlots()
|
||||
const value = computed(() => props.modelValue || {})
|
||||
const rows = computed(() => Array.isArray(props.modelValue) ? props.modelValue : [])
|
||||
const visibleFields = computed(() => (props.section.fields || []).filter(
|
||||
@@ -71,6 +76,19 @@ const visibleFields = computed(() => (props.section.fields || []).filter(
|
||||
!(typeof field.showWhen === 'function' && field.showWhen(value.value) === false) &&
|
||||
!(field.createOnly && props.mode !== 'create')
|
||||
))
|
||||
const fieldsWithInsert = computed(() => {
|
||||
const keys = props.insertBeforeKeys || []
|
||||
let inserted = false
|
||||
return visibleFields.value.map((field) => {
|
||||
const insertBefore = Boolean(slots.insert) && !inserted && keys.includes(field.key)
|
||||
if (insertBefore) inserted = true
|
||||
return { field, insertBefore }
|
||||
})
|
||||
})
|
||||
const appendInsert = computed(() => {
|
||||
if (!slots.insert || !(props.insertBeforeKeys || []).length) return false
|
||||
return !fieldsWithInsert.value.some((item) => item.insertBefore)
|
||||
})
|
||||
|
||||
function fieldOptions(field, rowIndex = -1) {
|
||||
const base = field.options || props.options[field.optionKey] || (field.optionKey === 'users' ? props.options.staff : []) || []
|
||||
|
||||
@@ -1,27 +1,49 @@
|
||||
<template>
|
||||
<section class="market-form-section">
|
||||
<h2>{{ filter ? '项目筛选' : '项目归属' }}<span v-if="required && !editing && !filter"> *</span></h2>
|
||||
<component :is="embedded ? 'div' : 'section'" :class="rootClass">
|
||||
<h2 v-if="!embedded">{{ filter ? '项目筛选' : '项目归属' }}<span v-if="required && !editing && !filter"> *</span></h2>
|
||||
<label v-else :id="labelId">所属项目<b v-if="required && !editing && !filter">*</b></label>
|
||||
<template v-if="editing">
|
||||
<van-cell title="所属项目" :value="projectLabel(record)" />
|
||||
<van-cell title="项目组" :value="record.teamName || '—'" />
|
||||
<van-cell label="归属调整请使用 Web 项目归属维护" />
|
||||
<template v-if="embedded">
|
||||
<div class="market-form-field__control market-form-field__control--text" :aria-labelledby="labelId">
|
||||
{{ projectLabel(record) }}
|
||||
</div>
|
||||
<p class="market-form-field__tip">项目组:{{ record.teamName || '—' }};归属调整请使用 Web 项目归属维护</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<van-cell title="所属项目" :value="projectLabel(record)" />
|
||||
<van-cell title="项目组" :value="record.teamName || '—'" />
|
||||
<van-cell label="归属调整请使用 Web 项目归属维护" />
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<PresignSelectField
|
||||
:model-value="modelValue || ''"
|
||||
:options="selectOptions"
|
||||
:disabled="disabled || loading || Boolean(error)"
|
||||
:loading="loading"
|
||||
searchable
|
||||
title="所属项目"
|
||||
:placeholder="filter ? '全部项目' : '请选择所属项目'"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
<van-cell v-if="loading" title="正在加载项目…" />
|
||||
<van-cell v-else-if="error" :label="error" title="项目加载失败,点击重试" is-link @click="load" />
|
||||
<van-cell v-else-if="!options.length" :label="required && !filter ? '请联系管理员配置可选项目后再保存' : '当前没有可选项目'" />
|
||||
<div :class="embedded ? 'market-form-field__control' : undefined">
|
||||
<PresignSelectField
|
||||
:model-value="modelValue || ''"
|
||||
:options="selectOptions"
|
||||
:disabled="disabled || loading || Boolean(error)"
|
||||
:loading="loading"
|
||||
searchable
|
||||
title="所属项目"
|
||||
:placeholder="filter ? '全部项目' : '请选择所属项目'"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="embedded">
|
||||
<p v-if="loading" class="market-form-field__tip">正在加载项目…</p>
|
||||
<button v-else-if="error" type="button" class="market-form-field__tip market-form-field__tip--action" @click="load">
|
||||
项目加载失败,点击重试({{ error }})
|
||||
</button>
|
||||
<p v-else-if="!options.length" class="market-form-field__tip">
|
||||
{{ required && !filter ? '请联系管理员配置可选项目后再保存' : '当前没有可选项目' }}
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<van-cell v-if="loading" title="正在加载项目…" />
|
||||
<van-cell v-else-if="error" :label="error" title="项目加载失败,点击重试" is-link @click="load" />
|
||||
<van-cell v-else-if="!options.length" :label="required && !filter ? '请联系管理员配置可选项目后再保存' : '当前没有可选项目'" />
|
||||
</template>
|
||||
</template>
|
||||
</section>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -35,6 +57,7 @@ const props = defineProps({
|
||||
bizType: { type: String, required: true },
|
||||
editing: { type: Boolean, default: false },
|
||||
filter: { type: Boolean, default: false },
|
||||
embedded: { type: Boolean, default: false },
|
||||
context: { type: Object, default: null },
|
||||
disabled: { type: Boolean, default: false },
|
||||
record: { type: Object, default: () => ({}) }
|
||||
@@ -44,7 +67,9 @@ const options = ref([])
|
||||
const required = ref(false)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const labelId = `project-assignment-${Math.random().toString(36).slice(2, 9)}`
|
||||
let controller
|
||||
const rootClass = computed(() => (props.embedded ? 'market-form-field' : 'market-form-section'))
|
||||
const selectOptions = computed(() => [
|
||||
...(!required.value || props.filter ? [{ text: props.filter ? '全部项目' : '暂不归属', value: '' }] : []),
|
||||
...options.value
|
||||
|
||||
@@ -40,7 +40,8 @@ export function createListFilters(module, source = '') {
|
||||
result.source = SOURCES.has(text(source)) ? text(source) : 'cell_storage'
|
||||
return {
|
||||
source: result.source,
|
||||
protocolStatus: result.protocolStatus
|
||||
protocolStatus: result.protocolStatus,
|
||||
projectId: ''
|
||||
}
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -15,11 +15,13 @@ test('creates isolated defaults for every market module', () => {
|
||||
|
||||
assert.deepEqual(first, {
|
||||
source: 'cell_storage',
|
||||
protocolStatus: 'sxz'
|
||||
protocolStatus: 'sxz',
|
||||
projectId: ''
|
||||
})
|
||||
assert.deepEqual(second, {
|
||||
source: 'research_donation',
|
||||
protocolStatus: ''
|
||||
protocolStatus: '',
|
||||
projectId: ''
|
||||
})
|
||||
assert.deepEqual(createListFilters('presign'), { orderStatus: '' })
|
||||
assert.deepEqual(createListFilters('payment-chase'), { chaseType: '', payMode: '' })
|
||||
@@ -66,8 +68,14 @@ test('drops incomplete ranges and counts only user-selectable filters', () => {
|
||||
}), { progressStatus: 'yjq' })
|
||||
assert.equal(activeFilterCount('protocol', {
|
||||
source: 'cell_storage',
|
||||
protocolStatus: 'sxz'
|
||||
protocolStatus: 'sxz',
|
||||
projectId: ''
|
||||
}), 1)
|
||||
assert.equal(activeFilterCount('protocol', {
|
||||
source: 'research_donation',
|
||||
protocolStatus: 'sxz',
|
||||
projectId: 'p1'
|
||||
}), 2)
|
||||
assert.equal(activeFilterCount('freight', {
|
||||
transportMode: '', sampleSource: 'research_donation'
|
||||
}), 1)
|
||||
|
||||
@@ -313,6 +313,7 @@ test('protocol list uses inline protocol-status dropdown like presign instead of
|
||||
|
||||
assert.match(protocol, /inlineDropdownFilters/)
|
||||
assert.match(protocol, /全部协议状态/)
|
||||
assert.match(protocol, /source:\s*'research_donation'/)
|
||||
assert.match(protocol, /filterFields:\s*\[\]/)
|
||||
assert.doesNotMatch(protocol, /createTimeRange/)
|
||||
assert.doesNotMatch(protocol, /sampleCollectTimeRange/)
|
||||
@@ -320,6 +321,25 @@ test('protocol list uses inline protocol-status dropdown like presign instead of
|
||||
assert.doesNotMatch(filters, /createTimeRange/)
|
||||
assert.match(genericList, /inlineDropdownFilters/)
|
||||
assert.match(genericList, /market-list__filter-row/)
|
||||
assert.match(genericList, /showProtocolProjectFilter/)
|
||||
assert.match(genericList, /全部项目/)
|
||||
assert.doesNotMatch(genericList, /ProjectAssignmentField/)
|
||||
})
|
||||
|
||||
test('protocol form embeds project assignment inside protocol info before status fields', () => {
|
||||
const form = source('../views/market/MarketModuleForm.vue')
|
||||
const formSection = source('./components/MarketFormSection.vue')
|
||||
const projectField = source('./components/ProjectAssignmentField.vue')
|
||||
|
||||
assert.match(form, /insert-before-keys="\['protocolStatus', 'donationStatus'\]"/)
|
||||
assert.match(form, /#insert/)
|
||||
assert.match(form, /embedded/)
|
||||
assert.match(form, /<ProjectAssignmentField[\s\S]*?embedded/)
|
||||
assert.doesNotMatch(form, /ProjectAssignmentField v-if="context\.module === 'protocol'"/)
|
||||
assert.match(formSection, /insertBeforeKeys/)
|
||||
assert.match(formSection, /name="insert"/)
|
||||
assert.match(projectField, /embedded/)
|
||||
assert.match(projectField, /market-form-field/)
|
||||
})
|
||||
|
||||
test('protocol list exposes mobile batch selection and sequential authorized deletion', () => {
|
||||
|
||||
@@ -478,6 +478,12 @@ export default {
|
||||
allText: '全部协议状态',
|
||||
source: 'cell_storage',
|
||||
options: CELL_PROTOCOL_STATUS_OPTIONS.map((item) => ({ text: item.label, value: item.value }))
|
||||
},
|
||||
{
|
||||
key: 'protocolStatus',
|
||||
allText: '全部协议状态',
|
||||
source: 'research_donation',
|
||||
options: DONATION_PROTOCOL_STATUS_OPTIONS.map((item) => ({ text: item.label, value: item.value }))
|
||||
}
|
||||
],
|
||||
statusLabel(detail) {
|
||||
|
||||
@@ -1247,6 +1247,25 @@ body.van-toast--unclickable .presign-order-page * {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.market-form-field > .market-form-field__tip,
|
||||
.market-form-field .market-form-field__control--text .market-form-field__tip {
|
||||
display: block;
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.market-form-field > .market-form-field__tip--action {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #0a9f6d;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.market-form-field > .van-field,
|
||||
.market-form-field__control {
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -12,25 +12,52 @@
|
||||
</van-empty>
|
||||
</div>
|
||||
<template v-else>
|
||||
<ProjectAssignmentField v-if="context.module === 'protocol'" ref="projectAssignment"
|
||||
v-model="formData.projectId" :biz-type="formData.source" :editing="mode === 'edit'"
|
||||
:record="formData" :disabled="fieldsLocked" @update:model-value="dirty = true" />
|
||||
<MarketFormSection
|
||||
v-for="section in leadingSections"
|
||||
:key="section.key"
|
||||
:section="section"
|
||||
:model-value="sectionValue(section)"
|
||||
:options="formOptions"
|
||||
:source="formData.source"
|
||||
:mode="mode"
|
||||
:uploading="false"
|
||||
:disabled="fieldsLocked"
|
||||
:search-customers="searchProtocolCustomers"
|
||||
@update:model-value="setSectionValue(section, $event)"
|
||||
@upload="uploadField"
|
||||
@field-change="handleFieldChange"
|
||||
@customer-select="handleCustomerSelect"
|
||||
/>
|
||||
<template v-for="section in leadingSections" :key="section.key">
|
||||
<MarketFormSection
|
||||
v-if="!(context.module === 'protocol' && section.key === 'protocol')"
|
||||
:section="section"
|
||||
:model-value="sectionValue(section)"
|
||||
:options="formOptions"
|
||||
:source="formData.source"
|
||||
:mode="mode"
|
||||
:uploading="false"
|
||||
:disabled="fieldsLocked"
|
||||
:search-customers="searchProtocolCustomers"
|
||||
@update:model-value="setSectionValue(section, $event)"
|
||||
@upload="uploadField"
|
||||
@field-change="handleFieldChange"
|
||||
@customer-select="handleCustomerSelect"
|
||||
/>
|
||||
<MarketFormSection
|
||||
v-else
|
||||
:section="section"
|
||||
:model-value="sectionValue(section)"
|
||||
:options="formOptions"
|
||||
:source="formData.source"
|
||||
:mode="mode"
|
||||
:uploading="false"
|
||||
:disabled="fieldsLocked"
|
||||
:search-customers="searchProtocolCustomers"
|
||||
:insert-before-keys="['protocolStatus', 'donationStatus']"
|
||||
@update:model-value="setSectionValue(section, $event)"
|
||||
@upload="uploadField"
|
||||
@field-change="handleFieldChange"
|
||||
@customer-select="handleCustomerSelect"
|
||||
>
|
||||
<template #insert>
|
||||
<ProjectAssignmentField
|
||||
ref="projectAssignment"
|
||||
embedded
|
||||
v-model="formData.projectId"
|
||||
:biz-type="formData.source"
|
||||
:editing="mode === 'edit'"
|
||||
:record="formData"
|
||||
:disabled="fieldsLocked"
|
||||
@update:model-value="dirty = true"
|
||||
/>
|
||||
</template>
|
||||
</MarketFormSection>
|
||||
</template>
|
||||
|
||||
<section
|
||||
v-if="context.module === 'protocol' && formData.source === 'cell_storage' && showDepositSection"
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
:show-header="false"
|
||||
>
|
||||
<div class="market-list__filters">
|
||||
<ProjectAssignmentField v-if="context.module === 'protocol'" v-model="listFilters.projectId"
|
||||
:biz-type="listStatus" filter :context="context" @update:model-value="search" />
|
||||
<div
|
||||
v-if="listFilterTabs.length"
|
||||
class="market-source-tabs"
|
||||
@@ -59,15 +57,24 @@
|
||||
<span v-if="activeFilters" class="market-list__filter-count">{{ activeFilters }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="inlineDropdownFilters.length" class="market-list__filter-row">
|
||||
<div
|
||||
v-if="inlineDropdownFilters.length || showProtocolProjectFilter"
|
||||
class="market-list__filter-row"
|
||||
>
|
||||
<van-dropdown-menu active-color="#0a9f6d">
|
||||
<van-dropdown-item
|
||||
v-for="field in inlineDropdownFilters"
|
||||
:key="field.key"
|
||||
:key="`${field.source || 'all'}:${field.key}`"
|
||||
:model-value="listFilters[field.key]"
|
||||
:options="inlineFilterOptionsByKey[field.key] || []"
|
||||
@change="(value) => onInlineFilterChange(field.key, value)"
|
||||
/>
|
||||
<van-dropdown-item
|
||||
v-if="showProtocolProjectFilter"
|
||||
:model-value="listFilters.projectId || ''"
|
||||
:options="protocolProjectFilterOptions"
|
||||
@change="(value) => onInlineFilterChange('projectId', value)"
|
||||
/>
|
||||
</van-dropdown-menu>
|
||||
</div>
|
||||
</div>
|
||||
@@ -154,11 +161,15 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ProjectAssignmentField from '../../market/components/ProjectAssignmentField.vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { showConfirmDialog, showSuccessToast, showToast } from 'vant'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { fetchMarketFormOptions, fetchMarketList, performMarketAction } from '../../api/market.js'
|
||||
import {
|
||||
fetchMarketFormOptions,
|
||||
fetchMarketList,
|
||||
fetchProjectOptions,
|
||||
performMarketAction
|
||||
} from '../../api/market.js'
|
||||
import { intersectAllowedActions } from '../../market/action-policy.js'
|
||||
import { deleteMarketItemsSequentially } from '../../market/batch-delete.js'
|
||||
import {
|
||||
@@ -169,6 +180,7 @@ import {
|
||||
import { marketSearchPlaceholder } from '../../market/list-presenter.js'
|
||||
import { marketErrorMessage } from '../../market/market-error.js'
|
||||
import { getMarketModule } from '../../market/module-registry.js'
|
||||
import { projectOptions } from '../../market/project-assignment.js'
|
||||
import {
|
||||
getMarketRouteContext,
|
||||
MARKET_MODULE_TITLES,
|
||||
@@ -247,6 +259,12 @@ const inlineDropdownFilters = computed(() => {
|
||||
const source = listFilters.value.source || listStatus.value
|
||||
return rows.filter((field) => !field.source || field.source === source)
|
||||
})
|
||||
const showProtocolProjectFilter = computed(() => context.value.module === 'protocol')
|
||||
const protocolProjectOptions = ref([])
|
||||
const protocolProjectFilterOptions = computed(() => [
|
||||
{ text: '全部项目', value: '' },
|
||||
...protocolProjectOptions.value
|
||||
])
|
||||
const activeFilters = computed(() =>
|
||||
activeFilterCount(context.value.module, listFilters.value)
|
||||
)
|
||||
@@ -299,6 +317,7 @@ const allDeletableSelected = computed(() =>
|
||||
)
|
||||
let requestSequence = 0
|
||||
let abortController = null
|
||||
let projectOptionsController = null
|
||||
let searchTimer = null
|
||||
|
||||
function normalizeList(result) {
|
||||
@@ -360,7 +379,32 @@ function queueSearch() {
|
||||
searchTimer = window.setTimeout(search, 350)
|
||||
}
|
||||
|
||||
async function loadProtocolProjectOptions() {
|
||||
projectOptionsController?.abort()
|
||||
if (!showProtocolProjectFilter.value) {
|
||||
protocolProjectOptions.value = []
|
||||
return
|
||||
}
|
||||
const current = new AbortController()
|
||||
projectOptionsController = current
|
||||
const bizType = listFilters.value.source || listStatus.value || 'cell_storage'
|
||||
try {
|
||||
const data = await fetchProjectOptions(bizType, {
|
||||
signal: current.signal,
|
||||
filter: true,
|
||||
context: context.value
|
||||
})
|
||||
if (projectOptionsController !== current) return
|
||||
protocolProjectOptions.value = projectOptions(data)
|
||||
} catch (error) {
|
||||
if (projectOptionsController === current && error?.name !== 'AbortError') {
|
||||
protocolProjectOptions.value = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadListFilterOptions() {
|
||||
await loadProtocolProjectOptions()
|
||||
const optionKeys = [
|
||||
...sheetFilterFields.value.map((field) => field.optionKey),
|
||||
...inlineDropdownFilters.value.map((field) => field.optionKey)
|
||||
@@ -596,6 +640,7 @@ function switchListStatus(value) {
|
||||
tab: next
|
||||
}
|
||||
router.replace({ query: nextQuery }).catch(() => {})
|
||||
loadProtocolProjectOptions()
|
||||
loadPage(true)
|
||||
}
|
||||
|
||||
@@ -644,6 +689,7 @@ onBeforeUnmount(() => {
|
||||
const requestId = ++requestSequence
|
||||
listState.value = invalidateMarketRequestState(listState.value, requestId)
|
||||
abortController?.abort()
|
||||
projectOptionsController?.abort()
|
||||
window.clearTimeout(searchTimer)
|
||||
})
|
||||
</script>
|
||||
@@ -20,7 +20,13 @@
|
||||
v-for="option in displayOptions"
|
||||
:key="option.id"
|
||||
:value="option.id"
|
||||
:label="team ? option.teamName : `${option.projectName} · ${option.teamName}`"
|
||||
:label="
|
||||
team
|
||||
? option.teamName
|
||||
: teamId
|
||||
? option.projectName
|
||||
: `${option.projectName} · ${option.teamName}`
|
||||
"
|
||||
:disabled="!filter && option.disabled"
|
||||
/>
|
||||
</el-select>
|
||||
@@ -43,6 +49,8 @@
|
||||
const props = defineProps<{
|
||||
filter?: boolean
|
||||
team?: boolean
|
||||
/** When selecting projects, keep options under this team. */
|
||||
teamId?: string
|
||||
modelValue?: string
|
||||
bizType?: BizType
|
||||
disabled?: boolean
|
||||
@@ -54,15 +62,18 @@
|
||||
ready: [value: boolean]
|
||||
}>()
|
||||
const options = ref<ProjectOption[]>([])
|
||||
const displayOptions = computed(() =>
|
||||
props.team
|
||||
? [
|
||||
...new Map(
|
||||
options.value.map((option) => [option.teamId, { ...option, id: option.teamId }])
|
||||
).values()
|
||||
]
|
||||
const displayOptions = computed(() => {
|
||||
if (props.team) {
|
||||
return [
|
||||
...new Map(
|
||||
options.value.map((option) => [option.teamId, { ...option, id: option.teamId }])
|
||||
).values()
|
||||
]
|
||||
}
|
||||
return props.teamId
|
||||
? options.value.filter((option) => option.teamId === props.teamId)
|
||||
: options.value
|
||||
)
|
||||
})
|
||||
const loading = ref(false)
|
||||
const error = ref(false),
|
||||
retry = ref(0)
|
||||
|
||||
@@ -143,57 +143,58 @@
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="项目归属维护"
|
||||
width="720px"
|
||||
width="560px"
|
||||
class="dialog-form-layout"
|
||||
append-to-body
|
||||
modal-class="protocol-dialog-mask"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form label-position="top"
|
||||
><el-form-item label="目标项目" required
|
||||
><ProjectSelect
|
||||
v-model="target"
|
||||
:key="selected.id"
|
||||
<el-form class="form-grid-2" label-position="top">
|
||||
<el-form-item label="原所属项目组">
|
||||
<el-input :model-value="selected.teamName || '未归属'" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="原所属项目">
|
||||
<el-input :model-value="selected.projectName || '未归属'" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="新项目组" required>
|
||||
<ProjectSelect
|
||||
:key="`team-${selected.id}`"
|
||||
team
|
||||
v-model="targetTeamId"
|
||||
: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
|
||||
></el-form>
|
||||
<el-alert
|
||||
title="请先预览全部关联单据,再确认调整。关联集合或归属发生变化时需要重新预览。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
/>
|
||||
<el-table :data="previewRows" class="list-table" stripe
|
||||
><el-table-column
|
||||
type="index"
|
||||
label="序号"
|
||||
min-width="68"
|
||||
align="center"
|
||||
header-align="center" />
|
||||
<el-table-column prop="bizNo" label="关联单据" min-width="200" /><el-table-column
|
||||
prop="projectName"
|
||||
label="原项目"
|
||||
min-width="160"
|
||||
/></el-table>
|
||||
<template #footer
|
||||
><el-button @click="visible = false"
|
||||
@update:model-value="onTargetTeamChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="新项目" required>
|
||||
<ProjectSelect
|
||||
:key="`project-${selected.id}-${targetTeamId}`"
|
||||
v-model="target"
|
||||
:team-id="targetTeamId"
|
||||
:biz-type="selected.bizType"
|
||||
:disabled="!targetTeamId"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="调整原因" required class="form-col-span-2">
|
||||
<el-input v-model="reason" type="textarea" :maxlength="500" show-word-limit />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false"
|
||||
><el-icon><Close /></el-icon>取消</el-button
|
||||
><el-button :loading="busy" @click="preview"
|
||||
><el-icon><View /></el-icon>预览关联单据</el-button
|
||||
><el-button
|
||||
>
|
||||
<el-button
|
||||
type="primary"
|
||||
:disabled="!previewRows.length || !reason.trim()"
|
||||
:disabled="!target || !reason.trim()"
|
||||
:loading="busy"
|
||||
@click="commit"
|
||||
><el-icon><Check /></el-icon>确认调整</el-button
|
||||
></template
|
||||
>
|
||||
>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<el-dialog
|
||||
v-model="historyVisible"
|
||||
title="归属审计记录"
|
||||
width="800px"
|
||||
width="1100px"
|
||||
append-to-body
|
||||
class="dialog-form-layout"
|
||||
><el-table :data="historyRows" class="list-table" stripe
|
||||
@@ -233,7 +234,7 @@
|
||||
presignOrderStatusTagType,
|
||||
applicationStatusTagType
|
||||
} from '@/utils/status-tag-registry'
|
||||
import { Search, Edit, Clock, Close, View, Check, RefreshRight } from '@element-plus/icons-vue'
|
||||
import { Search, Edit, Clock, Close, 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'
|
||||
@@ -268,10 +269,10 @@
|
||||
loadError = ref(false),
|
||||
visible = ref(false),
|
||||
busy = ref(false),
|
||||
targetTeamId = ref(''),
|
||||
target = ref(''),
|
||||
reason = ref(''),
|
||||
selected = ref<ProjectRecord>({}),
|
||||
previewRows = ref<ProjectRecord[]>([]),
|
||||
historyVisible = ref(false),
|
||||
historyRows = ref<ProjectRecord[]>([])
|
||||
const visibleColumns = computed(() =>
|
||||
@@ -361,47 +362,54 @@
|
||||
}
|
||||
function open(row: ProjectRecord) {
|
||||
selected.value = row
|
||||
targetTeamId.value = ''
|
||||
target.value = ''
|
||||
reason.value = ''
|
||||
previewRows.value = []
|
||||
visible.value = true
|
||||
}
|
||||
async function preview() {
|
||||
function onTargetTeamChange() {
|
||||
target.value = ''
|
||||
}
|
||||
async function commit() {
|
||||
if (!targetTeamId.value) {
|
||||
ElMessage.warning('请选择新项目组')
|
||||
return
|
||||
}
|
||||
if (!target.value) {
|
||||
ElMessage.warning('请选择目标项目')
|
||||
ElMessage.warning('请选择新项目')
|
||||
return
|
||||
}
|
||||
if (!reason.value.trim()) {
|
||||
ElMessage.warning('请填写调整原因')
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
const previewTarget = target.value
|
||||
const previewRecord = selected.value
|
||||
const commitTarget = target.value
|
||||
const commitRecord = selected.value
|
||||
try {
|
||||
const result = await previewAssignment({
|
||||
const preview = await previewAssignment({
|
||||
bizType: selected.value.bizType,
|
||||
id: selected.value.id,
|
||||
projectId: target.value
|
||||
})
|
||||
if (selected.value === previewRecord && target.value === previewTarget && visible.value) {
|
||||
previewRows.value = result.records
|
||||
if (selected.value !== commitRecord || target.value !== commitTarget || !visible.value) {
|
||||
return
|
||||
}
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
async function commit() {
|
||||
busy.value = true
|
||||
try {
|
||||
await saveAssignment({
|
||||
bizType: selected.value.bizType,
|
||||
id: selected.value.id,
|
||||
projectId: target.value,
|
||||
reason: reason.value.trim(),
|
||||
expectedAssignments: assignmentSnapshot(previewRows.value)
|
||||
expectedAssignments: assignmentSnapshot(preview.records)
|
||||
})
|
||||
if (selected.value !== commitRecord || target.value !== commitTarget || !visible.value) {
|
||||
return
|
||||
}
|
||||
visible.value = false
|
||||
ElMessage.success('归属已调整')
|
||||
await load()
|
||||
} catch {
|
||||
previewRows.value = []
|
||||
// API layer already surfaces the conflict / validation message.
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user