Compare commits

...
Author SHA1 Message Date
zlwuandCursor fefef41524 fix(mobile): 协议单表单将项目归属移入协议信息分区
按专员/经理协议单诉求,把项目归属嵌入协议信息卡片并置于协议状态上方,避免独立成卡打断填写顺序。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 17:57:57 +08:00
cykj e97f47b06a fix(mobile): 协议单列表项目筛选下移并补齐科研捐赠筛选 (#59)
fix(mobile): 协议单列表项目筛选下移并补齐科研捐赠筛选 (#59)
2026-11-05 09:14:29 +00:00
zlwuandCursor a4defb2c94 fix(mobile): 协议单列表项目筛选下移并补齐科研捐赠筛选
将项目筛选并入搜索框下方的 van-dropdown 行,与协议状态样式一致;科研捐赠 Tab 同步展示两项筛选,并修复捐赠列表按 protocolStatus 过滤 donation_status。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 17:13:15 +08:00
cykj 8717702b58 fix(personal-dev): 结束 Gitea 短页评论翻页 (#57)
Merge pull request #58 from cursor/issue-57-gitea-pagination

#57
2026-11-05 09:00:54 +00:00
zlwuandCursor d30dbad788 fix(personal-dev): 结束 Gitea 短页评论翻页 (#57)
避免评论列表短于 PAGE_SIZE 时因忽略 page 参数反复同页而触发分页上限。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 17:00:16 +08:00
cykj e92112e9fd fix(web-admin): 简化项目归属维护调整弹窗 (#52)
Merge pull request #56 from cursor/issue-52-assignment-dialog

关联 #52
2026-11-05 08:56:30 +00:00
zlwuandCursor d6dc768efb fix(web-admin): 加宽归属审计记录弹窗 (#52)
历史归属维护页审计表格列较多,800px 偏挤,调整为 1100px。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 16:54:22 +08:00
zlwuandCursor 96518c08fb fix(web-admin): 简化项目归属维护调整弹窗 (#52)
展示原项目组/项目,录入新项目组/项目与原因;确认时静默预览并仍级联关联单据。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 16:54:22 +08:00
cykj 54a34b24ba fix(web-admin): 协议单扁平行列表展示所属项目与项目组 (#55)
fix(web-admin): 协议单扁平行列表展示所属项目与项目组 (#55)

Refs: #54
2026-11-05 08:47:38 +00:00
14 changed files with 353 additions and 132 deletions

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):
+10 -5
View File
@@ -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:
+23
View File
@@ -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
+2 -1
View File
@@ -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
+11 -3
View File
@@ -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) {
+19
View File
@@ -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
}