feat(oa): add tripItinerary FormCreate control

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-07-30 20:00:54 +08:00
1 parent 33bf0c32a5
commit 0101e46ed9
4 files changed
+477

No files matched your search

@@ -0,0 +1,460 @@
<!-- FormCreate 自定义控件:出差行程列表(多卡片增删 · 半日制时长自动计算) -->
<template>
<div class="oa-trip-itinerary">
<div v-for="(leg, idx) in inner" :key="idx" class="oa-trip-leg">
<div class="oa-trip-leg__head">
<span class="oa-trip-leg__title">行程 {{ idx + 1 }}</span>
<el-button
v-if="!disabled"
link
type="danger"
:disabled="inner.length <= 1"
@click="remove(idx)"
>
<el-icon><Delete /></el-icon>
删除
</el-button>
</div>
<div class="oa-trip-leg__grid">
<div class="oa-trip-leg__field">
<label class="oa-trip-leg__label">交通工具</label>
<template v-if="disabled">
<span class="oa-trip-leg__readonly">{{ leg.transport || '—' }}</span>
</template>
<el-select
v-else
:model-value="leg.transport"
placeholder="请选择"
@update:model-value="(v) => patchLeg(idx, { transport: String(v || '') })"
>
<el-option
v-for="opt in transportOpts"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</div>
<div class="oa-trip-leg__field">
<label class="oa-trip-leg__label">单程往返</label>
<template v-if="disabled">
<span class="oa-trip-leg__readonly">{{ leg.tripType || '—' }}</span>
</template>
<el-select
v-else
:model-value="leg.tripType"
placeholder="请选择"
@update:model-value="(v) => patchLeg(idx, { tripType: String(v || '') })"
>
<el-option
v-for="opt in tripTypeOpts"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</div>
<div class="oa-trip-leg__field">
<label class="oa-trip-leg__label">出发城市</label>
<template v-if="disabled">
<span class="oa-trip-leg__readonly">{{ leg.fromCity || '—' }}</span>
</template>
<el-input
v-else
:model-value="leg.fromCity"
placeholder="请输入出发城市"
@update:model-value="(v) => patchLeg(idx, { fromCity: String(v || '') })"
/>
</div>
<div class="oa-trip-leg__field">
<label class="oa-trip-leg__label">目的城市</label>
<template v-if="disabled">
<span class="oa-trip-leg__readonly">{{ leg.toCity || '—' }}</span>
</template>
<el-input
v-else
:model-value="leg.toCity"
placeholder="请输入目的城市"
@update:model-value="(v) => patchLeg(idx, { toCity: String(v || '') })"
/>
</div>
<div class="oa-trip-leg__field">
<label class="oa-trip-leg__label">开始日期</label>
<template v-if="disabled">
<span class="oa-trip-leg__readonly">{{ leg.startDate || '—' }}</span>
</template>
<el-date-picker
v-else
:model-value="leg.startDate || undefined"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
@update:model-value="(v) => onScheduleChange(idx, 'startDate', v || '')"
/>
</div>
<div class="oa-trip-leg__field">
<label class="oa-trip-leg__label">开始时段</label>
<template v-if="disabled">
<span class="oa-trip-leg__readonly">{{ leg.startHalf || '—' }}</span>
</template>
<el-select
v-else
:model-value="leg.startHalf"
@update:model-value="(v) => onScheduleChange(idx, 'startHalf', String(v || ''))"
>
<el-option
v-for="opt in halfOpts"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</div>
<div class="oa-trip-leg__field">
<label class="oa-trip-leg__label">结束日期</label>
<template v-if="disabled">
<span class="oa-trip-leg__readonly">{{ leg.endDate || '—' }}</span>
</template>
<el-date-picker
v-else
:model-value="leg.endDate || undefined"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
@update:model-value="(v) => onScheduleChange(idx, 'endDate', v || '')"
/>
</div>
<div class="oa-trip-leg__field">
<label class="oa-trip-leg__label">结束时段</label>
<template v-if="disabled">
<span class="oa-trip-leg__readonly">{{ leg.endHalf || '—' }}</span>
</template>
<el-select
v-else
:model-value="leg.endHalf"
@update:model-value="(v) => onScheduleChange(idx, 'endHalf', String(v || ''))"
>
<el-option
v-for="opt in halfOpts"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</div>
<div class="oa-trip-leg__field oa-trip-leg__field--duration">
<label class="oa-trip-leg__label">时长(天)</label>
<div class="oa-trip-leg__duration-row">
<template v-if="disabled">
<span class="oa-trip-leg__readonly">
{{ leg.duration != null ? leg.duration : '—' }}
</span>
</template>
<template v-else>
<el-input-number
class="oa-trip-leg__duration-input"
:model-value="leg.duration ?? undefined"
:min="0"
:step="0.5"
:precision="1"
controls-position="right"
@update:model-value="(v) => onDurationChange(idx, v)"
/>
<el-button
v-if="leg.durationManual"
link
type="primary"
class="oa-trip-leg__restore"
@click="restoreAuto(idx)"
>
<el-icon><RefreshRight /></el-icon>
恢复自动计算
</el-button>
</template>
</div>
</div>
</div>
</div>
<el-button v-if="!disabled" class="oa-trip-itinerary__add" @click="add">
<el-icon><Plus /></el-icon>
新增行程
</el-button>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { Delete, Plus, RefreshRight } from '@element-plus/icons-vue'
import { calcTripLegDays, emptyTripLeg } from '@/utils/oa-trip-days'
defineOptions({ name: 'tripItinerary' })
type SelectOpt = { label: string; value: string }
type TripLeg = {
transport: string
tripType: string
fromCity: string
toCity: string
startDate: string
startHalf: string
endDate: string
endHalf: string
duration: number | null
durationManual: boolean
}
const DEFAULT_TRANSPORT_OPTIONS: SelectOpt[] = [
{ label: '飞机', value: '飞机' },
{ label: '火车', value: '火车' },
{ label: '汽车', value: '汽车' },
{ label: '其他', value: '其他' }
]
const DEFAULT_TRIP_TYPE_OPTIONS: SelectOpt[] = [
{ label: '单程', value: '单程' },
{ label: '往返', value: '往返' }
]
const HALF_OPTIONS: SelectOpt[] = [
{ label: '上午', value: '上午' },
{ label: '下午', value: '下午' }
]
const props = withDefaults(
defineProps<{
modelValue?: unknown
disabled?: boolean
transportOptions?: SelectOpt[]
tripTypeOptions?: SelectOpt[]
formCreateInject?: Record<string, unknown>
}>(),
{}
)
const emit = defineEmits<{
(e: 'update:modelValue', v: TripLeg[]): void
(e: 'change', v: TripLeg[]): void
}>()
const transportOpts = computed(() =>
props.transportOptions?.length ? props.transportOptions : DEFAULT_TRANSPORT_OPTIONS
)
const tripTypeOpts = computed(() =>
props.tripTypeOptions?.length ? props.tripTypeOptions : DEFAULT_TRIP_TYPE_OPTIONS
)
const halfOpts = HALF_OPTIONS
function normalizeLeg(raw: unknown): TripLeg {
const base = emptyTripLeg()
const src = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {}
const durationRaw = src.duration
const duration =
durationRaw == null || durationRaw === ''
? null
: Number.isFinite(Number(durationRaw))
? Number(durationRaw)
: null
return {
transport: String(src.transport ?? base.transport ?? ''),
tripType: String(src.tripType ?? base.tripType ?? ''),
fromCity: String(src.fromCity ?? base.fromCity ?? ''),
toCity: String(src.toCity ?? base.toCity ?? ''),
startDate: String(src.startDate ?? base.startDate ?? ''),
startHalf: String(src.startHalf ?? base.startHalf ?? '上午'),
endDate: String(src.endDate ?? base.endDate ?? ''),
endHalf: String(src.endHalf ?? base.endHalf ?? '下午'),
duration,
durationManual: src.durationManual === true
}
}
function initLegs(v: unknown): TripLeg[] {
if (!Array.isArray(v) || v.length === 0) {
return [normalizeLeg(emptyTripLeg())]
}
return v.map((leg) => normalizeLeg(leg))
}
const inner = ref<TripLeg[]>(initLegs(props.modelValue))
let syncing = false
watch(
() => props.modelValue,
(v) => {
if (syncing) return
inner.value = initLegs(v)
},
{ deep: true }
)
function recalcLeg(idx: number) {
const leg = inner.value[idx]
if (leg.durationManual) return
leg.duration = calcTripLegDays(leg.startDate, leg.startHalf, leg.endDate, leg.endHalf)
}
function commit() {
syncing = true
const out = inner.value.map((leg) => ({ ...leg }))
emit('update:modelValue', out)
emit('change', out)
nextTick(() => {
syncing = false
})
}
function patchLeg(idx: number, patch: Partial<TripLeg>) {
Object.assign(inner.value[idx], patch)
commit()
}
function onScheduleChange(idx: number, field: keyof TripLeg, val: string) {
inner.value[idx][field] = val as never
if (!inner.value[idx].durationManual) {
recalcLeg(idx)
}
commit()
}
function onDurationChange(idx: number, val: number | undefined) {
inner.value[idx].duration = val == null || !Number.isFinite(val) ? null : val
inner.value[idx].durationManual = true
commit()
}
function restoreAuto(idx: number) {
inner.value[idx].durationManual = false
recalcLeg(idx)
commit()
}
function add() {
inner.value.push(normalizeLeg(emptyTripLeg()))
commit()
}
function remove(idx: number) {
if (inner.value.length <= 1) return
inner.value.splice(idx, 1)
commit()
}
</script>
<style scoped lang="scss">
.oa-trip-itinerary {
box-sizing: border-box;
width: 100%;
max-width: 100%;
min-width: 0;
}
.oa-trip-leg {
box-sizing: border-box;
margin-bottom: 12px;
padding: 14px 16px;
border: 1px solid var(--mint-border, #cbd5e1);
border-radius: 10px;
background: var(--primary-bg, #ecfdf5);
}
.oa-trip-leg__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.oa-trip-leg__title {
color: var(--primary-dark, #059669);
font-size: 14px;
font-weight: 600;
}
.oa-trip-leg__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 16px;
}
.oa-trip-leg__field {
min-width: 0;
}
.oa-trip-leg__field--duration {
grid-column: 1 / -1;
}
.oa-trip-leg__label {
display: block;
margin-bottom: 6px;
color: var(--mint-text-muted, #64748b);
font-size: 12px;
line-height: 1.4;
}
.oa-trip-leg__readonly {
display: block;
color: var(--mint-text, #0f172a);
font-size: 14px;
line-height: 1.5;
word-break: break-all;
}
.oa-trip-leg__duration-row {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
align-items: center;
}
.oa-trip-leg__duration-input {
width: 160px;
max-width: 100%;
}
.oa-trip-leg__duration-input :deep(.el-input__inner) {
text-align: left;
}
.oa-trip-leg__restore {
flex-shrink: 0;
}
.oa-trip-itinerary__add {
margin-top: 4px;
}
.oa-trip-leg :deep(.el-select),
.oa-trip-leg :deep(.el-input),
.oa-trip-leg :deep(.el-date-editor),
.oa-trip-leg :deep(.el-input-number) {
width: 100%;
max-width: 100%;
}
.oa-trip-leg :deep(.el-input__wrapper),
.oa-trip-leg :deep(.el-select__wrapper) {
min-height: 36px;
}
@media (max-width: 640px) {
.oa-trip-leg__grid {
grid-template-columns: 1fr;
}
.oa-trip-leg__field--duration {
grid-column: auto;
}
}
</style>
+2
View File
@@ -28,6 +28,7 @@ import { setupOaLeaveKit } from '@/utils/oa-form-create-leave-kit'
import { setupOaConfirmationKit } from '@/utils/oa-form-create-confirmation-kit'
import { setupOaOvertimeKit } from '@/utils/oa-form-create-overtime-kit'
import { setupOaResignationKit } from '@/utils/oa-form-create-resignation-kit'
import { setupOaTripItinerary } from '@/utils/oa-form-create-trip-itinerary'
// FormCreate 按字符串解析 el-input / fc-radio 等,主应用是按需自动导入,不会全局注册;
// 必须挂上官方 auto-import,否则发起审批/详情里动态表单控件会残缺(如单选项只剩文字)。
@@ -42,6 +43,7 @@ setupOaOwnerPersonSelect(FcDesigner.formCreate)
setupOaOwnerDeptSelect(FcDesigner.formCreate)
setupOaPostSelect(FcDesigner.formCreate)
setupOaReceiveAccountSelect(FcDesigner.formCreate)
setupOaTripItinerary(FcDesigner.formCreate)
setupOaLeaveKit(FcDesigner.formCreate)
setupOaConfirmationKit(FcDesigner.formCreate)
setupOaOvertimeKit(FcDesigner.formCreate)
+2
View File
@@ -25,6 +25,7 @@ import { setupOaLeaveKit } from '@/utils/oa-form-create-leave-kit'
import { setupOaConfirmationKit } from '@/utils/oa-form-create-confirmation-kit'
import { setupOaOvertimeKit } from '@/utils/oa-form-create-overtime-kit'
import { setupOaResignationKit } from '@/utils/oa-form-create-resignation-kit'
import { setupOaTripItinerary } from '@/utils/oa-form-create-trip-itinerary'
import App from './App.vue'
const app = createApp(App)
@@ -49,6 +50,7 @@ setupOaOwnerPersonSelect(FcDesigner.formCreate)
setupOaOwnerDeptSelect(FcDesigner.formCreate)
setupOaPostSelect(FcDesigner.formCreate)
setupOaReceiveAccountSelect(FcDesigner.formCreate)
setupOaTripItinerary(FcDesigner.formCreate)
setupOaLeaveKit(FcDesigner.formCreate)
setupOaConfirmationKit(FcDesigner.formCreate)
setupOaOvertimeKit(FcDesigner.formCreate)
@@ -0,0 +1,13 @@
import FcDesigner from '@form-create/designer'
import OaTripItinerary from '@/components/oa-approval/OaTripItinerary.vue'
const FLAG = '__oaTripItineraryConfigured'
export function setupOaTripItinerary(formCreate?: { component?: (name: string, c: unknown) => void }) {
if ((FcDesigner as any)[FLAG]) return
FcDesigner.component('tripItinerary', OaTripItinerary)
if (typeof formCreate?.component === 'function') {
formCreate.component('tripItinerary', OaTripItinerary)
}
;(FcDesigner as any)[FLAG] = true
}