This commit is contained in:
2026-10-30 11:20:03 +08:00
parent bda33648f3
commit d6ef770e48
11 changed files with 355 additions and 109 deletions
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, ref, shallowRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { ApiError } from '@/api/types'
import { accessTokenIdentity } from '@/api/session'
import { useAuthStore } from '@/modules/auth/public'
@@ -9,7 +9,8 @@ import DataTable, { type DataTableColumn } from '@/components/common/DataTable.v
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
import type { TableAction } from '@/components/common/tableActions'
import ProcurementDetailPanel from '../components/ProcurementDetailPanel.vue'
import { rejectProcurement, confirmProcurement, createProcurement, getProcurement, getProcurementWorkflow, listProcurements, type ProcurementDetail, type ProcurementSummary, type ProcurementWorkflow } from '../api/procurement'
import ProcurementDecisionDialog from '../components/ProcurementDecisionDialog.vue'
import { createProcurement, getProcurement, getProcurementWorkflow, listProcurements, type ProcurementDetail, type ProcurementSummary, type ProcurementWorkflow } from '../api/procurement'
import { procurementDirections, directionLabel, regionText, buildProcurementCreate, dateTime, draftBudgetTotal, emptyProcurementDraft, money, procurementStatuses, statusLabel, statusTone } from '../procurementPresentation'
import { requireOrderBudget } from '../api/orderBudgets'
import { procurementRequestKey } from '../procurementSubmission'
@@ -27,6 +28,7 @@ const formRef = ref<FormInstance>()
const formRules: FormRules<typeof form> = {
procurementDirection: [{ required: true, message: '请选择采购方向', trigger: 'change' }],
productName: [{ required: true, whitespace: true, message: '请输入商品名称', trigger: 'blur' }],
productUrl: [{ required: true, whitespace: true, message: '请输入商品链接', trigger: 'blur' }],
purchaseQuantity: [{ required: true, whitespace: true, message: '请输入采购数量', trigger: 'blur' }],
budgetUnitPrice: [{ required: true, whitespace: true, message: '请输入预算单价', trigger: 'blur' }],
currency: [{ required: true, message: '请选择预算币种', trigger: 'change' }],
@@ -42,13 +44,12 @@ const submitError = ref(''), submittedOrder = ref('')
const submitting = ref(false), loading = ref(false), error = ref('')
const detail = shallowRef<ProcurementDetail | null>(null)
const workflow = shallowRef<ProcurementWorkflow | null>(null)
const confirming = ref(false), confirmError = ref('')
const canConfirm = computed(() => canCreate.value && workflow.value?.status === 'WAIT_CUSTOMER_CONFIRM')
const decisionTarget = shallowRef<ProcurementSummary | null>(null)
const decision = ref<'confirm' | 'reject'>('confirm')
const rows = ref<ProcurementSummary[]>([]), total = ref(0)
const query = reactive({ page: 1, size: 20, orderNo: '', purchaseCategory: '', productName: '', status: '' })
let requestSequence = 0, active = true, inFlight = ''
let createAttempt: { fingerprint: string; key: string } | null = null
let confirmAttempt: { fingerprint: string; key: string } | null = null
const queryModel = computed<QueryModel>({
get: () => ({ orderNo: query.orderNo, purchaseCategory: query.purchaseCategory, productName: query.productName, status: query.status }),
set: value => { for (const key of ['orderNo', 'purchaseCategory', 'productName', 'status'] as const) query[key] = String(value[key] || '') },
@@ -59,15 +60,27 @@ const queryFields: QueryField[] = [
{ key: 'status', label: '履约进度', type: 'select', options: Object.entries(procurementStatuses).map(([value, label]) => ({ value, label })) },
]
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' },
{ prop: 'procurementDirection', label: '采购方向', minWidth: 170, slotName: 'direction' },
{ prop: 'orderNo', label: '采购单号', minWidth: 250, slotName: 'orderNo' },
{ prop: 'purchaseCategory', label: '采购品类', width: 120 }, { prop: 'productName', label: '商品名称', minWidth: 200 },
{ prop: 'productSpecification', label: '商品规格', minWidth: 160 }, { prop: 'purchaseQuantity', label: '采购数量(件)', width: 135 },
{ prop: 'budgetUnitPrice', label: '预算单价', width: 155, slotName: 'budget' },
{ prop: 'status', label: '履约进度', width: 180, slotName: 'status' }, { prop: 'createdAt', label: '下单时间', width: 185, slotName: 'createdAt' },
{ prop: 'status', label: '履约进度', width: 180, slotName: 'status' }, { prop: 'reviewRemark', label: '拒绝原因', minWidth: 220, slotName: 'reviewRemark' }, { prop: 'createdAt', label: '下单时间', width: 185, slotName: 'createdAt' },
]
function rowActions(row: object): TableAction[] { return [{ key: 'detail', label: '详情', run: () => openDetail(row as ProcurementSummary) }] }
function rowActions(row: object): TableAction[] {
const order = row as ProcurementSummary
const actions: TableAction[] = [{ key: 'detail', label: '详情', run: () => openDetail(order) }]
if (canCreate.value && order.status === 'WAIT_CUSTOMER_CONFIRM') {
actions.push({ key: 'reject', label: '驳回', run: () => beginDecision(order, 'reject') }, { key: 'confirm', label: '确认采购结果', run: () => beginDecision(order, 'confirm') })
}
return actions
}
function beginDecision(order: ProcurementSummary, kind: 'confirm' | 'reject') {
if (!canCreate.value || order.status !== 'WAIT_CUSTOMER_CONFIRM') return
decision.value = kind; decisionTarget.value = order
}
function decisionChanged() { decisionTarget.value = null; void loadList() }
function openDetail(row: ProcurementSummary) { if (row.orderNo) void router.push({ name: 'cross-border-procurement-detail', params: { orderNo: row.orderNo } }) }
function returnToList() { if (canList.value) void router.push({ name: 'cross-border-procurement' }) }
function message(cause: unknown) {
@@ -95,39 +108,6 @@ async function loadDetail() {
catch (cause) { if (sequence === requestSequence) error.value = message(cause) }
finally { if (sequence === requestSequence) { loading.value = false; inFlight = '' } }
}
async function confirmReceived() {
if (!canConfirm.value || confirming.value || !workflow.value) return
const owner = identity.value, targetOrder = orderNo.value, version = workflow.value.version
confirming.value = true; confirmError.value = ''
try {
await ElMessageBox.confirm('确认本单收货数量及照片,确认后平台将安排出库。', '确认采购结果', { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' })
if (!active || owner !== identity.value || targetOrder !== orderNo.value) return
const fingerprint = JSON.stringify([owner, targetOrder, version])
if (confirmAttempt?.fingerprint !== fingerprint) confirmAttempt = { fingerprint, key: procurementRequestKey() }
const result = await confirmProcurement(targetOrder, { version, idempotencyKey: confirmAttempt.key })
if (!active || owner !== identity.value || targetOrder !== orderNo.value) return
workflow.value = result; confirmAttempt = null
if (detail.value) detail.value = { ...detail.value, order: { ...detail.value.order, status: result.status, version: result.version } }
ElMessage.success('采购结果已确认')
} catch (cause) {
if (cause !== 'cancel' && cause !== 'close' && active && owner === identity.value && targetOrder === orderNo.value) confirmError.value = message(cause)
} finally { confirming.value = false }
}
// 驳回携带当前版本和必填原因,失败保留当前采购详情。
async function rejectReceived() {
if (!canConfirm.value || confirming.value || !workflow.value) return
const owner = identity.value, targetOrder = orderNo.value
confirming.value = true; confirmError.value = ''
try {
const answer = await ElMessageBox.prompt('请说明采购结果不符合要求的原因,平台将重新上传照片。', '驳回采购结果', { inputType: 'textarea', inputValidator: value => !!value?.trim() && value.trim().length <= 1000 || '请填写驳回原因,最多1000字', confirmButtonText: '提交驳回', cancelButtonText: '取消' })
if (!active || owner !== identity.value || targetOrder !== orderNo.value || !workflow.value) return
const result = await rejectProcurement(targetOrder, { version: workflow.value.version, idempotencyKey: procurementRequestKey(), reason: answer.value.trim() })
if (!active || owner !== identity.value || targetOrder !== orderNo.value) return
workflow.value = result; ElMessage.success('已驳回,等待平台重新上传照片')
} catch (cause) {
if (cause !== 'cancel' && cause !== 'close' && active && owner === identity.value && targetOrder === orderNo.value) confirmError.value = message(cause)
} finally { confirming.value = false }
}
function search() { if (query.page === 1) void loadList(); else query.page = 1 }
function resetQuery() { const reload = query.page === 1; Object.assign(query, { page: 1, orderNo: '', purchaseCategory: '', productName: '', status: '' }); if (reload) void loadList() }
function resetForm() { submitError.value = ''; submittedOrder.value = ''; Object.assign(form, emptyProcurementDraft()); formRef.value?.clearValidate(); createAttempt = null }
@@ -145,14 +125,14 @@ async function submit() {
const result = await createProcurement({ ...payload, idempotencyKey: createAttempt.key })
if (!active || owner !== identity.value) return
createAttempt = null; submittedOrder.value = result.order.orderNo; ElMessage.success('采购订单已提交')
if (canList.value) await router.push({ name: 'cross-border-procurement-detail', params: { orderNo: result.order.orderNo } })
if (canList.value) await router.push({ name: 'cross-border-procurement' })
} catch (cause) { if (/已释放|已过期/.test(message(cause))) createAttempt = null; if (active && owner === identity.value) submitError.value = message(cause) }
finally { submitting.value = false }
}
watch(() => [mode.value, orderNo.value, query.page, query.size, identity.value], (values, previous) => {
if (values[4] !== previous?.[4]) { rows.value = []; total.value = 0; createAttempt = null }
if (values[4] !== previous?.[4] || values[1] !== previous?.[1]) confirmAttempt = null
++requestSequence; inFlight = ''; loading.value = false; error.value = ''; detail.value = null; workflow.value = null; confirmError.value = ''
decisionTarget.value = null
++requestSequence; inFlight = ''; loading.value = false; error.value = ''; detail.value = null; workflow.value = null
if (mode.value === 'create') resetForm()
else if (mode.value === 'detail') void loadDetail()
else void loadList()
@@ -172,14 +152,15 @@ onBeforeUnmount(() => { active = false; ++requestSequence })
<template #budget="{ row }">{{ money((row as ProcurementSummary).budgetUnitPrice, (row as ProcurementSummary).currency) }}</template>
<template #direction="{ row }">{{ directionLabel((row as ProcurementSummary).procurementDirection) }}</template>
<template #status="{ row }"><el-tag :type="statusTone((row as ProcurementSummary).status)" size="small">{{ statusLabel((row as ProcurementSummary).status, (row as ProcurementSummary).procurementDirection) }}</el-tag></template>
<template #reviewRemark="{ row }"><span v-if="(row as ProcurementSummary).status === 'REJECTED'">{{ (row as ProcurementSummary).reviewRemark || '未填写原因' }}</span><span v-else>—</span></template>
<template #createdAt="{ row }">{{ dateTime((row as ProcurementSummary).createdAt) }}</template>
</DataTable>
</div>
</template>
<section v-else-if="mode === 'detail'" v-loading="loading" class="procurement-detail" aria-label="采购订单详情">
<header class="procurement-heading"><div><el-button link type="primary" @click="returnToList">← 返回列表</el-button><h2>{{ detail?.order.orderNo || '采购订单详情' }}</h2></div><div><el-tag v-if="detail" :type="statusTone(workflow?.status || detail.order.status)">{{ statusLabel(workflow?.status || detail.order.status, detail.order.procurementDirection) }}</el-tag><el-button v-if="canConfirm" type="danger" :disabled="confirming" @click="rejectReceived">驳回</el-button><el-button v-if="canConfirm" type="primary" :loading="confirming" @click="confirmReceived">确认采购结果</el-button><el-button :disabled="loading || confirming" @click="loadDetail">刷新</el-button></div></header>
<header class="procurement-heading"><div><el-button link type="primary" @click="returnToList">← 返回列表</el-button><h2>{{ detail?.order.orderNo || '采购订单详情' }}</h2></div><div><el-tag v-if="detail" :type="statusTone(workflow?.status || detail.order.status)">{{ statusLabel(workflow?.status || detail.order.status, detail.order.procurementDirection) }}</el-tag><el-button :disabled="loading" @click="loadDetail">刷新</el-button></div></header>
<div v-if="error" class="procurement-error" role="alert"><span>{{ error }}</span><el-button :disabled="loading" @click="loadDetail">重新加载</el-button></div>
<template v-else-if="detail && workflow"><el-alert v-if="confirmError" :title="confirmError" type="error" :closable="false" /><el-alert v-if="workflow.rejectionReason" title="采购结果驳回原因" :description="workflow.rejectionReason" type="warning" :closable="false" /><ProcurementDetailPanel :detail="detail" :workflow="workflow" /></template><el-skeleton v-else :rows="8" animated />
<template v-else-if="detail && workflow"><el-alert v-if="workflow.rejectionReason" title="采购结果驳回原因" :description="workflow.rejectionReason" type="warning" :closable="false" /><ProcurementDetailPanel :detail="detail" :workflow="workflow" /></template><el-skeleton v-else :rows="8" animated />
</section>
<section v-else class="procurement-create" aria-label="采购下单">
<header class="procurement-create-heading">
@@ -225,7 +206,7 @@ onBeforeUnmount(() => { active = false; ++requestSequence })
<el-form-item label="采购数量(件)" for="procurement-quantity" prop="purchaseQuantity" required><el-input id="procurement-quantity" v-model.trim="form.purchaseQuantity" inputmode="numeric" maxlength="18" placeholder="正整数" /></el-form-item>
<el-form-item label="预算单价" for="procurement-price" prop="budgetUnitPrice" required><el-input id="procurement-price" v-model.trim="form.budgetUnitPrice" inputmode="decimal" maxlength="20" placeholder="请明确填写;免费样品填 0" /></el-form-item>
<el-form-item label="预算币种" for="procurement-currency" prop="currency" required><el-select id="procurement-currency" v-model="form.currency" popper-class="procurement-create-select"><el-option label="美元 USD" value="USD" /><el-option label="人民币 CNY" value="CNY" /></el-select></el-form-item>
<el-form-item class="full" label="商品链接" for="procurement-url"><el-input id="procurement-url" v-model.trim="form.productUrl" maxlength="2000" placeholder="https://..." /></el-form-item>
<el-form-item class="full" label="商品链接" for="procurement-url" prop="productUrl" required><el-input id="procurement-url" v-model.trim="form.productUrl" maxlength="2000" placeholder="https://..." /></el-form-item>
</div>
<div class="procurement-budget-total">货款预算总上限:{{ money(budgetTotal, form.currency) }}</div>
</div>
@@ -237,7 +218,7 @@ onBeforeUnmount(() => { active = false; ++requestSequence })
<el-form-item label="联系电话" for="procurement-phone" prop="contactPhone" required><el-input id="procurement-phone" v-model.trim="form.contactPhone" maxlength="64" type="tel" placeholder="快递可联系的电话" /></el-form-item>
<el-form-item class="full" :label="regionText('完整国内地址', form.procurementDirection)" for="procurement-address" prop="receivingAddress" required><el-input id="procurement-address" v-model.trim="form.receivingAddress" maxlength="1000" :placeholder="form.procurementDirection === 'DOMESTIC_TO_OVERSEAS' ? '国家、州/省、市、邮编及详细地址' : '省、市、区县、街道及详细门牌'" /></el-form-item>
<el-form-item class="full" label="采购要求" for="procurement-requirements">
<el-input id="procurement-requirements" v-model="form.remark" type="textarea" :rows="4" maxlength="1000" resize="vertical" aria-describedby="procurement-requirements-count" />
<el-input id="procurement-requirements" v-model="form.remark" type="textarea" :rows="4" maxlength="1000" resize="vertical" placeholder="填写采购要求(选填)" aria-describedby="procurement-requirements-count" />
<div id="procurement-requirements-count" class="procurement-character-count">{{ form.remark.length }} / 1000</div>
</el-form-item>
</div>
@@ -246,6 +227,7 @@ onBeforeUnmount(() => { active = false; ++requestSequence })
<footer class="procurement-form-actions"><el-button v-if="canList" :disabled="submitting" @click="returnToList">返回列表</el-button><el-button type="primary" :loading="submitting" :disabled="!companyName || !identity" @click="submit">提交采购订单</el-button></footer>
</template>
</section>
<ProcurementDecisionDialog v-if="decisionTarget" :key="`${identity}:${decisionTarget.orderNo}:${decision}`" :order="decisionTarget" :decision="decision" @close="decisionTarget = null" @changed="decisionChanged" />
</section>
</template>