Files
client-web/src/modules/cross-border/views/ProcurementView.vue
T
2026-10-20 11:54:44 +08:00

187 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, ref, shallowRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ApiError } from '@/api/types'
import { accessTokenIdentity } from '@/api/session'
import { useAuthStore } from '@/modules/auth/public'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
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 { createProcurement, getProcurement, listProcurements, type ProcurementCreate, type ProcurementDetail, type ProcurementSummary } from '../api/procurement'
import { buildProcurementCreate, dateTime, draftBudgetTotal, emptyProcurementDraft, money, procurementStatuses, statusLabel, statusTone } from '../procurementPresentation'
import { definitelyRejected, procurementRequestKey, restoreProcurementSubmission } from '../procurementSubmission'
const props = withDefaults(defineProps<{ createPage?: boolean; detailPage?: boolean }>(), { createPage: false, detailPage: false })
const auth = useAuthStore(), route = useRoute(), router = useRouter()
const mode = computed(() => props.detailPage ? 'detail' : props.createPage ? 'create' : 'list')
const orderNo = computed(() => typeof route.params.orderNo === 'string' ? route.params.orderNo : '')
const canCreate = computed(() => auth.hasPermission('merchant:cross-border:procurement:create'))
const canList = computed(() => auth.hasPermission('merchant:cross-border:procurement:list'))
const identity = computed(() => auth.token ? accessTokenIdentity(auth.token) : null)
const storageKey = computed(() => identity.value ? `cc-procurement-submit:${encodeURIComponent(identity.value)}` : null)
const companyName = computed(() => auth.profile.tenantName || (auth.user?.tenantName && !/^企业租户 \d+$/.test(auth.user.tenantName) ? auth.user.tenantName : ''))
const form = reactive(emptyProcurementDraft())
const pending = shallowRef<ProcurementCreate | null>(null)
const recoveryError = ref(''), submitError = ref(''), submittedOrder = ref('')
const submitting = ref(false), loading = ref(false), error = ref('')
const detail = shallowRef<ProcurementDetail | null>(null)
const rows = ref<ProcurementSummary[]>([]), total = ref(0)
const query = reactive({ page: 1, size: 20, orderNo: '', purchaseCategory: '', productName: '', status: '' })
let requestSequence = 0, active = true, inFlight = ''
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] || '') },
})
const queryFields: QueryField[] = [
{ key: 'orderNo', label: '采购单号', type: 'input' }, { key: 'purchaseCategory', label: '采购品类', type: 'input' },
{ key: 'productName', label: '商品名称', type: 'input' },
{ 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' },
{ 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' },
]
function rowActions(row: object): TableAction[] { return [{ key: 'detail', label: '详情', run: () => openDetail(row as ProcurementSummary) }] }
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) {
if (cause instanceof ApiError && ((cause.status || 0) >= 500 || cause.code.startsWith('B'))) return '服务暂不可用,请稍后重试'
return cause instanceof Error ? cause.message : '请求失败,请稍后重试'
}
async function loadList() {
const params = { page: query.page, size: query.size, orderNo: query.orderNo || undefined, productName: query.productName || undefined, purchaseCategory: query.purchaseCategory || undefined, status: query.status || undefined }
const key = JSON.stringify([identity.value, params])
if (inFlight === key) return
const sequence = ++requestSequence; inFlight = key; loading.value = true; error.value = ''
try { const result = await listProcurements(params); if (sequence === requestSequence) { rows.value = result.records; total.value = result.total } }
catch (cause) { if (sequence === requestSequence) { rows.value = []; total.value = 0; error.value = message(cause) } }
finally { if (sequence === requestSequence) { loading.value = false; inFlight = '' } }
}
async function loadDetail() {
if (!orderNo.value) { error.value = '采购单号缺失'; return }
const key = JSON.stringify([identity.value, orderNo.value])
if (inFlight === key) return
const sequence = ++requestSequence; inFlight = key; loading.value = true; error.value = ''; detail.value = null
try { const result = await getProcurement(orderNo.value); if (sequence === requestSequence) detail.value = result }
catch (cause) { if (sequence === requestSequence) error.value = message(cause) }
finally { if (sequence === requestSequence) { loading.value = false; inFlight = '' } }
}
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 populate(payload: ProcurementCreate) {
Object.assign(form, { purchaseCategory: payload.purchaseCategory, productName: payload.productName, productSpecification: payload.productSpecification || '', productUrl: payload.productUrl || '',
purchaseQuantity: String(payload.purchaseQuantity), budgetUnitPrice: String(payload.budgetUnitPrice), currency: payload.currency, receivingAddress: payload.receivingAddress,
contactName: payload.contactName, contactPhone: payload.contactPhone, remark: payload.remark || '' })
}
function restorePending() {
pending.value = null; recoveryError.value = ''; submitError.value = ''; submittedOrder.value = ''; Object.assign(form, emptyProcurementDraft())
if (!storageKey.value) return
try { const saved = sessionStorage.getItem(storageKey.value); if (saved) { pending.value = Object.freeze(restoreProcurementSubmission(saved)); populate(pending.value) } }
catch { recoveryError.value = '上次提交记录暂无法恢复,请先在订单列表或通过客服核对提交结果,避免重复下单。' }
}
async function submit() {
if (submitting.value || !canCreate.value || recoveryError.value || !storageKey.value) return
submitError.value = ''; const owner = identity.value, key = storageKey.value; const resuming = Boolean(pending.value)
let started = false
try {
if (!pending.value) {
const payload = buildProcurementCreate(form, companyName.value, procurementRequestKey())
try { sessionStorage.setItem(key, JSON.stringify(payload)) } catch { throw new Error('浏览器暂无法保留提交信息,请允许会话存储后重试') }
pending.value = Object.freeze(payload)
}
submitting.value = true; started = true
const result = await createProcurement(pending.value)
sessionStorage.removeItem(key)
if (!active || owner !== identity.value) return
pending.value = null; submittedOrder.value = result.order.orderNo; ElMessage.success('采购订单已提交')
if (canList.value) await router.push({ name: 'cross-border-procurement-detail', params: { orderNo: result.order.orderNo } })
} catch (cause) {
if (!active || owner !== identity.value) return
if (!resuming && cause instanceof ApiError && definitelyRejected(cause)) { sessionStorage.removeItem(key); pending.value = null }
submitError.value = started && pending.value ? `${message(cause)}。提交结果尚未确认,请再次确认上次提交结果。` : 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 }
++requestSequence; inFlight = ''; loading.value = false; error.value = ''; detail.value = null
if (mode.value === 'create') restorePending()
else if (mode.value === 'detail') void loadDetail()
else void loadList()
}, { immediate: true })
onBeforeUnmount(() => { active = false; ++requestSequence })
</script>
<template>
<section class="procurement-page client-ui" :aria-busy="loading || submitting">
<template v-if="mode === 'list'">
<QueryToolbar v-model="queryModel" layout="grid" ariaLabel="采购订单查询条件" :fields="queryFields" :search-on-reset="false" @search="search" @reset="resetQuery" />
<div class="procurement-table-card">
<DataTable v-model:page="query.page" v-model:page-size="query.size" :data="rows" :columns="columns" :row-actions="rowActions" :loading="loading" :total="total" :page-sizes="[10, 20, 50, 100]" row-key="orderNo" empty-text="暂无采购订单">
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="router.push({ name: 'cross-border-procurement-create' })">新增下单</el-button></template>
<template v-if="error" #error><div class="procurement-error" role="alert"><span>{{ error }}</span><el-button :disabled="loading" @click="loadList">重新加载</el-button></div></template>
<template #orderNo="{ row }"><button class="procurement-link" type="button" @click="openDetail(row as ProcurementSummary)">{{ (row as ProcurementSummary).orderNo || '—' }}</button></template>
<template #budget="{ row }">{{ money((row as ProcurementSummary).budgetUnitPrice, (row as ProcurementSummary).currency) }}</template>
<template #status="{ row }"><el-tag :type="statusTone((row as ProcurementSummary).status)" size="small">{{ statusLabel((row as ProcurementSummary).status) }}</el-tag></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(detail.order.status)">{{ statusLabel(detail.order.status) }}</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>
<ProcurementDetailPanel v-else-if="detail" :detail="detail" /><el-skeleton v-else :rows="8" animated />
</section>
<section v-else class="procurement-create" aria-label="采购下单">
<header class="procurement-heading"><div><h2>海外采购下单</h2><p>一单一个商品及规格;预算仅授权样品货款,运费及关税另行核定。</p></div><el-button v-if="canList" @click="returnToList">返回列表</el-button></header>
<el-result v-if="!canCreate" icon="warning" title="没有采购下单权限" sub-title="请联系企业管理员配置权限" />
<el-result v-else-if="submittedOrder" icon="success" title="采购订单已提交" :sub-title="submittedOrder"><template #extra><el-button @click="restorePending">新建另一单</el-button></template></el-result>
<template v-else>
<el-alert v-if="recoveryError" :title="recoveryError" type="error" :closable="false" />
<el-alert v-else-if="pending" title="已保留本次提交内容。请再次确认上次提交结果,确认前不修改订单信息。" type="warning" :closable="false" />
<el-alert v-if="submitError" class="submit-error" :title="submitError" type="error" :closable="false" />
<el-form :model="form" label-position="top" :disabled="submitting || Boolean(pending) || Boolean(recoveryError)" @submit.prevent="submit">
<h3>商品与预算</h3><div class="procurement-form-grid">
<el-form-item label="采购品类"><el-input v-model.trim="form.purchaseCategory" maxlength="128" placeholder="请输入采购品类(选填)" /></el-form-item>
<el-form-item label="商品名称" required><el-input v-model.trim="form.productName" maxlength="255" placeholder="请输入商品名称" /></el-form-item>
<el-form-item label="商品规格"><el-input v-model.trim="form.productSpecification" maxlength="500" placeholder="规格、型号" /></el-form-item>
<el-form-item label="采购数量(件)" required><el-input v-model.trim="form.purchaseQuantity" inputmode="numeric" maxlength="18" placeholder="正整数" /></el-form-item>
<el-form-item label="预算单价" required><el-input v-model.trim="form.budgetUnitPrice" inputmode="decimal" maxlength="20" placeholder="请明确填写;免费样品填 0" /></el-form-item>
<el-form-item label="预算币种" required><el-select v-model="form.currency"><el-option label="美元 USD" value="USD" /><el-option label="人民币 CNY" value="CNY" /></el-select></el-form-item>
<el-form-item class="full" label="商品链接"><el-input v-model.trim="form.productUrl" maxlength="2000" placeholder="https://…" /></el-form-item>
</div><p class="procurement-hint">货款预算总上限:<strong>{{ money(draftBudgetTotal(form), form.currency) }}</strong>。零预算仅授权免费样品;实际货款按供应商实付核定。</p>
<h3>最终国内收件资料</h3><div class="procurement-form-grid">
<el-form-item label="收件人" required><el-input v-model.trim="form.contactName" maxlength="128" placeholder="实际收件人姓名" /></el-form-item>
<el-form-item label="联系电话" required><el-input v-model.trim="form.contactPhone" maxlength="64" type="tel" placeholder="快递可联系的电话" /></el-form-item>
<el-form-item class="full" label="完整国内地址" required><el-input v-model.trim="form.receivingAddress" maxlength="1000" placeholder="省、市、区县、街道及详细门牌" /></el-form-item>
<el-form-item class="full" label="采购要求"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="其他采购要求(选填)" /></el-form-item>
</div>
</el-form>
<footer class="procurement-form-actions"><el-button type="primary" :loading="submitting" :disabled="Boolean(recoveryError) || (!pending && !companyName) || !identity" @click="submit">{{ pending ? '确认上次提交结果' : '提交采购订单' }}</el-button></footer>
</template>
</section>
</section>
</template>
<style scoped>
.procurement-page { display: grid; gap: var(--client-section-gap); min-width: 0; }
.procurement-table-card, .procurement-detail, .procurement-create { min-width: 0; border: 1px solid var(--client-border); border-radius: var(--client-radius); background: #fff; }
.procurement-table-card { overflow: hidden; }.procurement-detail, .procurement-create { padding: 16px 20px 24px; }
.procurement-heading { display: flex; justify-content: space-between; align-items: center; gap: 20px; min-height: 54px; border-bottom: 1px solid var(--border); padding-bottom: 12px; }
.procurement-heading > div { display: flex; align-items: center; gap: 14px; min-width: 0; }.procurement-heading h2 { margin: 0; color: var(--navy); font-size: 18px; overflow-wrap: anywhere; }
.procurement-create .procurement-heading > div { display: block; }.procurement-heading p { margin: 7px 0 0; font-size: 13px; color: var(--muted); }
.procurement-link { padding: 0; border: 0; background: transparent; color: var(--client-action); font: inherit; cursor: pointer; text-align: left; }.procurement-link:hover { text-decoration: underline; }
.procurement-error { min-height: 160px; display: flex; justify-content: center; align-items: center; gap: 16px; color: #b42318; }.procurement-error span { max-width: 900px; overflow-wrap: anywhere; }
.procurement-form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 24px; }.procurement-form-grid .full { grid-column: 1 / -1; }.procurement-form-grid :deep(.el-select) { width: 100%; }
.procurement-create h3 { margin: 22px 0 16px; color: var(--navy); font-size: 14px; }.procurement-hint { margin: 0 0 20px; line-height: 1.8; font-size: 13px; color: var(--muted); }.procurement-hint strong { color: var(--navy); }
.procurement-form-actions { display: flex; justify-content: flex-end; gap: 12px; padding-top: 16px; border-top: 1px solid var(--border); }.submit-error { margin: 12px 0; }
@media (max-width: 767px) {
.procurement-form-grid { grid-template-columns: 1fr; }.procurement-heading, .procurement-heading > div { flex-wrap: wrap; }
}
</style>