Files
admin-web/src/modules/appointment-calendar/WorkCommandForm.vue
T

106 lines
14 KiB
Vue
Raw Normal View History

<script setup lang="ts">
2026-10-20 15:48:27 +08:00
import InlineHelp from '@/shared/components/InlineHelp.vue'
import { computed, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listSuppliers, type Supplier } from '@/modules/suppliers'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { submitWorkCommand, type WorkAction, type WorkDetail, type WorkCommand, type WorkCommandPayload, type WorkCommandResult } from './workApi'
import { actionAllowed, commandName } from './workPresentation'
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
const props = defineProps<{ detail: WorkDetail; action: WorkAction; refreshing: boolean }>()
const emit = defineEmits<{ saved: [result: WorkCommandResult, command: WorkCommand['command']]; refresh: [] }>()
const auth = useAuthStore(), access = useAccessPreviewStore()
const command = computed(() => commandName(props.action))
const item = computed(() => props.detail.item)
const form = reactive({ flow: item.value.flow === 'DIRECT' ? 'DIRECT' : 'TRANSFER', ownerId: item.value.owner?.id || auth.user?.id || '', followupDate: item.value.followupDate || '', note: '', reason: '', date: item.value.target?.date || '', startTime: item.value.target?.startTime || '', endDate: item.value.target?.endDate || '', endTime: item.value.target?.endTime || '', bookingRequired: props.detail.requirement?.bookingRequired ?? true, receivingRequirements: props.detail.requirement?.receivingRequirements || '', recipientKind: 'EXTERNAL', recipientId: '', recipientName: '' })
const baseline = ref(JSON.stringify(form)), pending = ref<WorkCommand | null>(null), saving = ref(false), error = ref<ApiErrorDetails | null>(null)
const supplierKeyword = ref(''), suppliers = ref<Supplier[]>([]), supplierTotal = ref(0), supplierLoading = ref(false), supplierError = ref<ApiErrorDetails | null>(null)
let supplierRequest = 0
async function searchSuppliers() {
const request = ++supplierRequest; supplierError.value = null; suppliers.value = []; form.recipientId = ''
if (!access.can('platform:supplier:list')) { supplierError.value = { message: '当前账号没有供应商读取权限,请联系有权限的负责人选择车队。' }; return }
supplierLoading.value = true
try { const result = await listSuppliers({ page: 1, size: 20, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE', keyword: supplierKeyword.value }); if (request === supplierRequest) { suppliers.value = result.records; supplierTotal.value = result.total } }
catch (cause) { if (request === supplierRequest) supplierError.value = getApiErrorDetails(cause, '还空车队读取失败') }
finally { if (request === supplierRequest) supplierLoading.value = false }
}
function changeRecipient() { form.recipientId = ''; form.recipientName = ''; if (form.recipientKind === 'SUPPLIER') void searchSuppliers() }
useWorkDraft(computed(() => baseline.value !== JSON.stringify(form)), computed(() => saving.value || !!pending.value))
const owners = computed(() => {
const people = [item.value.owner, auth.user ? { id: auth.user.id, name: auth.user.name } : null, ...props.detail.relatedItems.map(row => row.owner)]
return [...new Map(people.filter((person): person is { id: string; name: string } => !!person?.id).map(person => [person.id, person])).values()]
})
const permission = computed(() => command.value === 'SAVE_FOLLOWUP' || command.value === 'RESOLVE_REVIEW' ? 'platform:appointment:followup' : 'platform:appointment:manage-work')
const allowed = computed(() => !!command.value && actionAllowed(item.value, props.action) && access.can(permission.value))
const missingContext = computed(() => {
if (command.value === 'RESOLVE_REVIEW' && props.detail.review == null) return '当前没有可复核的影响版本,请刷新事项。'
if (command.value === 'ACCEPT_RETURN' && props.detail.returnHandoff == null) return '当前没有有效的还空交接,请刷新事项。'
if (command.value === 'SAVE_REQUIREMENT' && form.date && !item.value.timezone) return '作业地点时区未核实,暂不能保存带日期的要求。'
return ''
})
function payload(): WorkCommandPayload | null {
const note = form.note.trim(), reason = form.reason.trim()
switch (command.value) {
case 'CONFIRM_ROUTE': return { command: 'CONFIRM_ROUTE', payload: { flow: form.flow === 'DIRECT' ? 'DIRECT' : 'TRANSFER', reason: reason || null } }
case 'SAVE_FOLLOWUP':
if (!form.ownerId || !note) { ElMessage.warning('请选择跟进负责人并填写本次回复'); return null }
return { command: 'SAVE_FOLLOWUP', payload: { ownerId: form.ownerId, followupDate: form.followupDate || null, note } }
case 'RESOLVE_REVIEW':
if (!props.detail.review || !note) { ElMessage.warning('请核对当前影响并填写处理结果'); return null }
return { command: 'RESOLVE_REVIEW', payload: { reviewVersion: props.detail.review.version, note } }
case 'SAVE_REQUIREMENT':
if (!reason || (!form.bookingRequired && !form.receivingRequirements.trim())) { ElMessage.warning('请填写处理说明;无需预约时必须填写真实收货要求'); return null }
if ((form.startTime || form.endDate || form.endTime) && !form.date) { ElMessage.warning('填写时段前需明确目标日期'); return null }
if (!!form.endDate !== !!form.endTime) { ElMessage.warning('结束日期和结束时间须同时填写或同时留空'); return null }
if (form.endDate && (!form.startTime || form.endDate + 'T' + form.endTime <= form.date + 'T' + form.startTime)) { ElMessage.warning('完整的当地结束时间须晚于开始时间'); return null }
return { command: 'SAVE_REQUIREMENT', payload: { target: form.date && item.value.timezone ? { date: form.date, startTime: form.startTime || null, endDate: form.endDate || null, endTime: form.endTime || null, timezone: item.value.timezone } : null, bookingRequired: form.bookingRequired, receivingRequirements: form.receivingRequirements.trim(), reason } }
case 'HANDOFF_RETURN': {
const person = owners.value.find(owner => owner.id === form.recipientId)
const supplier = suppliers.value.find(candidate => candidate.id === form.recipientId)
const recipient = form.recipientKind === 'USER' && person ? { kind: 'USER' as const, id: person.id, name: person.name } : form.recipientKind === 'SUPPLIER' && supplier && !supplierLoading.value && !supplierError.value ? { kind: 'SUPPLIER' as const, id: supplier.id, name: supplier.name } : form.recipientKind === 'EXTERNAL' ? { kind: 'EXTERNAL' as const, id: null, name: form.recipientName.trim() } : null
if (!recipient?.name || !reason) { ElMessage.warning('请明确接手方并填写转交原因'); return null }
return { command: 'HANDOFF_RETURN', payload: { recipient, reason } }
}
case 'ACCEPT_RETURN':
if (!props.detail.returnHandoff) return null
if (props.detail.returnHandoff.to.kind !== 'USER' && !note) { ElMessage.warning('请记录外部接手方已确认的反馈依据'); return null }
return { command: 'ACCEPT_RETURN', payload: { handoffVersion: props.detail.returnHandoff.version, note: note || null } }
default: return null
}
}
async function save() {
if (!allowed.value || props.refreshing || saving.value || missingContext.value) return
if (!pending.value) { const request = payload(); if (!request) return; pending.value = { ...request, expectedVersion: item.value.version, idempotencyKey: crypto.randomUUID() } }
saving.value = true; error.value = null
try {
const request = pending.value, result = await submitWorkCommand(item.value.workItemKey, request)
pending.value = null; baseline.value = JSON.stringify(form); ElMessage.success('已保存,工作待办已更新'); emit('saved', result, request.command)
} catch (cause) {
error.value = getApiErrorDetails(cause, '本次保存结果待确认,请重试同一次保存')
if (rejectedCommand(cause)) { pending.value = null; emit('refresh') }
} finally { saving.value = false }
}
</script>
<template>
<form class="density-form work-form" @submit.prevent="save">
2026-10-20 15:48:27 +08:00
<h3>{{ action.label }}<InlineHelp v-if="command === 'SAVE_FOLLOWUP'" title="跟进日期说明"><p>跟进日期不代替作业日期;未完成事项仍保留在待办中。</p></InlineHelp><InlineHelp v-if="command === 'RESOLVE_REVIEW'" title="核对结果说明"><p>原安排需要改期时,请先处理原安排,再记录核对结果。</p></InlineHelp></h3>
<p v-if="!allowed">当前仅可查看。此操作需要有效的服务端资格和对应操作权限。</p>
<p v-if="missingContext" role="alert">{{ missingContext }}</p>
<fieldset :disabled="!allowed || refreshing || saving || !!pending">
<template v-if="command === 'CONFIRM_ROUTE'"><label>运输方式<select v-model="form.flow"><option value="TRANSFER">先入海外仓,再分批送仓</option><option value="DIRECT">整柜直接送目的仓</option></select></label><label>路线说明<textarea v-model="form.reason" maxlength="1000" /></label></template>
<template v-else-if="command === 'SAVE_FOLLOWUP'"><label>跟进负责人<select v-model="form.ownerId" required><option value="" disabled>请选择负责人</option><option v-for="person in owners" :key="person.id" :value="person.id">{{ person.name }}</option></select></label><label>本次回复<textarea v-model="form.note" required maxlength="1000" /></label><label>下次跟进日期(可留空)<el-date-picker v-model="form.followupDate" type="date" :disabled="!allowed || refreshing || saving || !!pending" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label></template>
2026-10-20 15:48:27 +08:00
<template v-else-if="command === 'RESOLVE_REVIEW'"><p v-for="reason in detail.review?.reasons" :key="reason">{{ reason }}</p><label>本次核对结果<textarea v-model="form.note" required maxlength="1000" /></label></template>
<template v-else-if="command === 'SAVE_REQUIREMENT'"><label>目标日期(可暂不填)<el-date-picker v-model="form.date" type="date" :disabled="!allowed || refreshing || saving || !!pending" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><div class="time-fields"><label>开始时段(可留空)<el-time-picker v-model="form.startTime" :disabled="!allowed || refreshing || saving || !!pending" format="HH:mm" value-format="HH:mm" clearable /></label><label>结束日期(可留空)<el-date-picker v-model="form.endDate" type="date" :disabled="!allowed || refreshing || saving || !!pending" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><label>结束时段(可留空)<el-time-picker v-model="form.endTime" :disabled="!allowed || refreshing || saving || !!pending" format="HH:mm" value-format="HH:mm" clearable /></label></div><p>{{ item.timezone || '地点时区待核实' }}</p><label v-if="item.type === 'DELIVERY'"><input v-model="form.bookingRequired" type="checkbox">需要收货方预约</label><label>收货要求<textarea v-model="form.receivingRequirements" :required="!form.bookingRequired" maxlength="1000" /></label><label>处理说明<textarea v-model="form.reason" required maxlength="1000" /></label></template>
<template v-else-if="command === 'HANDOFF_RETURN'"><p>转交保留原预约和责任记录;接手方确认前持续跟进。</p><label>接手方类型<select v-model="form.recipientKind" @change="changeRecipient"><option value="EXTERNAL">外部车队 / 联系人</option><option value="SUPPLIER">已登记海外车队</option><option value="USER">当前关联负责人</option></select></label><label v-if="form.recipientKind === 'EXTERNAL'">接手方名称<input v-model="form.recipientName" required maxlength="200"></label><template v-else-if="form.recipientKind === 'SUPPLIER'"><label>查找海外车队<input v-model="supplierKeyword" maxlength="80" @keydown.enter.prevent="searchSuppliers"></label><el-button :loading="supplierLoading" @click="searchSuppliers">查询车队</el-button><LoadErrorState v-if="supplierError" title="还空车队不可用" :message="supplierError.message" @retry="searchSuppliers" /><label>接手车队<select v-model="form.recipientId" required :disabled="supplierLoading || !!supplierError"><option value="" disabled>请选择已登记海外车队</option><option v-for="supplier in suppliers" :key="supplier.id" :value="supplier.id">{{ supplier.name }}</option></select></label><p v-if="supplierTotal > suppliers.length">请输入更完整名称缩小查询范围。</p></template><label v-else>接手负责人<select v-model="form.recipientId" required><option value="" disabled>请选择</option><option v-for="person in owners" :key="person.id" :value="person.id">{{ person.name }}</option></select></label><label>转交原因<textarea v-model="form.reason" required maxlength="1000" /></label></template>
<template v-else-if="command === 'ACCEPT_RETURN'"><p>原责任方:{{ detail.returnHandoff?.from.name || '—' }} → 接手方:{{ detail.returnHandoff?.to.name || '—' }}</p><label>接手反馈依据<textarea v-model="form.note" :required="detail.returnHandoff?.to.kind !== 'USER'" maxlength="1000" /></label><p>确认接手只记录责任,实际还空仍按原业务操作。</p></template>
</fieldset>
<LoadErrorState v-if="error" title="工作操作未保存" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-button type="primary" native-type="submit" :disabled="!allowed || refreshing || !!missingContext" :loading="saving">{{ pending ? '重试本次保存' : command === 'CONFIRM_ROUTE' ? '确认路线,继续本柜工作' : '保存并更新待办' }}</el-button>
</form>
</template>
<style scoped>.time-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-3); }</style>