feat(oa): add tripItinerary FormCreate control
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user