feat(mobile-oa-h5): add kit shells, trip itinerary, and camera-friendly upload

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-11 12:23:21 +08:00
1 parent d5e61a1162
commit 834b1a59a7
6 files changed
+526 -35

No files matched your search

@@ -24,7 +24,18 @@
- `person` / `dept` / `post`:底部选择弹层,值 `{ id, name }`(部门展示全路径)
- `ownerPerson` / `ownerDept`:单选封装,`allowModify` 控制可否改选
- 仍为 stub:各 Kit(请假/加班/转正/离职/差旅)
## P2 套件(已落地)
- `leaveKit` / `overtimeKit` / `confirmationKit` / `resignationKit` / `tripKit`:与 Web 相同为 **slot 容器**,子字段由 `children` 渲染
- `tripItinerary`:行程多段编辑(交通/城市/日期时段)
- 说明:Web 端「时长自动计算 / 节假日联动」等运行时逻辑尚未移植,真模板联调时再对齐
## 附件拍照(真机 / 微信 web-view)
- **支持**:`van-uploader` 在微信内打开图片时,系统选择器通常提供「拍照」和「从相册选择」
- 默认**不**写死 `capture="camera"`,以便同时保留相册;若业务只要拍照可传 `props.capture = 'camera'`
- 非图片附件走文件选择;需 HTTPS 业务域名与有效 `token`
## 本地联调提示
@@ -0,0 +1,19 @@
<!-- 与 Web 套件一致:仅组合容器,子字段由 form-create children + slot 渲染 -->
<template>
<div class="oa-kit-shell" v-bind="$attrs">
<slot />
</div>
</template>
<script setup>
defineOptions({ inheritAttrs: false })
defineProps({
formCreateInject: { type: Object, default: null }
})
</script>
<style scoped>
.oa-kit-shell {
width: 100%;
}
</style>
@@ -0,0 +1,267 @@
<template>
<div class="oa-trip">
<div v-for="(leg, idx) in inner" :key="idx" class="oa-trip__leg">
<div class="oa-trip__head">
<strong>行程 {{ idx + 1 }}</strong>
<van-button
v-if="!disabled && inner.length > 1"
size="mini"
type="danger"
plain
@click="remove(idx)"
>
删除
</van-button>
</div>
<van-field
:model-value="leg.transport"
label="交通工具"
readonly
is-link
:disabled="disabled"
placeholder="请选择"
@click="!disabled && openSelect(idx, 'transport', transportOpts)"
/>
<van-field
:model-value="leg.tripType"
label="单程往返"
readonly
is-link
:disabled="disabled"
placeholder="请选择"
@click="!disabled && openSelect(idx, 'tripType', tripTypeOpts)"
/>
<van-field
:model-value="leg.fromCity"
label="出发城市"
:disabled="disabled"
placeholder="请输入"
@update:model-value="(v) => patch(idx, { fromCity: v })"
/>
<van-field
:model-value="leg.toCity"
label="目的城市"
:disabled="disabled"
placeholder="请输入"
@update:model-value="(v) => patch(idx, { toCity: v })"
/>
<van-field
:model-value="leg.startDate"
label="开始日期"
readonly
is-link
:disabled="disabled"
placeholder="选择日期"
@click="!disabled && openDate(idx, 'startDate')"
/>
<van-field
:model-value="leg.startHalf"
label="开始时段"
readonly
is-link
:disabled="disabled"
@click="!disabled && openSelect(idx, 'startHalf', halfOpts)"
/>
<van-field
:model-value="leg.endDate"
label="结束日期"
readonly
is-link
:disabled="disabled"
placeholder="选择日期"
@click="!disabled && openDate(idx, 'endDate')"
/>
<van-field
:model-value="leg.endHalf"
label="结束时段"
readonly
is-link
:disabled="disabled"
@click="!disabled && openSelect(idx, 'endHalf', halfOpts)"
/>
<van-field
:model-value="leg.duration == null ? '' : String(leg.duration)"
label="时长(天)"
type="number"
:disabled="disabled"
@update:model-value="(v) => patch(idx, { duration: v === '' ? null : Number(v) })"
/>
</div>
<van-button v-if="!disabled" block type="primary" plain size="small" @click="add">
添加行程
</van-button>
<van-popup v-model:show="pickerShow" position="bottom" round>
<van-picker
:columns="pickerColumns"
@confirm="onPickerConfirm"
@cancel="pickerShow = false"
/>
</van-popup>
<van-popup v-model:show="dateShow" position="bottom" round>
<van-date-picker
v-model="dateValue"
title="选择日期"
@confirm="onDateConfirm"
@cancel="dateShow = false"
/>
</van-popup>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
defineOptions({ name: 'tripItinerary' })
const DEFAULT_TRANSPORT = [
{ text: '飞机', value: '飞机' },
{ text: '火车', value: '火车' },
{ text: '汽车', value: '汽车' },
{ text: '其他', value: '其他' }
]
const DEFAULT_TRIP_TYPE = [
{ text: '单程', value: '单程' },
{ text: '往返', value: '往返' }
]
const halfOpts = [
{ text: '上午', value: '上午' },
{ text: '下午', value: '下午' }
]
function emptyLeg() {
return {
transport: '',
tripType: '',
fromCity: '',
toCity: '',
startDate: '',
startHalf: '上午',
endDate: '',
endHalf: '下午',
duration: null,
durationManual: false
}
}
const props = defineProps({
modelValue: { type: Array, default: () => [] },
disabled: { type: Boolean, default: false },
transportOptions: { type: Array, default: () => [] },
tripTypeOptions: { type: Array, default: () => [] },
formCreateInject: { type: Object, default: null }
})
const emit = defineEmits(['update:modelValue', 'change'])
const inner = ref(
Array.isArray(props.modelValue) && props.modelValue.length
? props.modelValue.map((x) => ({ ...emptyLeg(), ...x }))
: [emptyLeg()]
)
watch(
() => props.modelValue,
(v) => {
if (!Array.isArray(v)) return
const next = v.length ? v.map((x) => ({ ...emptyLeg(), ...x })) : [emptyLeg()]
if (JSON.stringify(next) !== JSON.stringify(inner.value)) inner.value = next
}
)
const transportOpts = computed(() => {
const raw = props.transportOptions?.length
? props.transportOptions
: DEFAULT_TRANSPORT.map((o) => ({ label: o.text, value: o.value }))
return raw.map((o) => ({ text: o.label || o.text, value: o.value }))
})
const tripTypeOpts = computed(() => {
const raw = props.tripTypeOptions?.length
? props.tripTypeOptions
: DEFAULT_TRIP_TYPE.map((o) => ({ label: o.text, value: o.value }))
return raw.map((o) => ({ text: o.label || o.text, value: o.value }))
})
function sync() {
emit('update:modelValue', inner.value.map((x) => ({ ...x })))
emit('change', inner.value.map((x) => ({ ...x })))
}
function patch(idx, patchObj) {
const next = inner.value.slice()
next[idx] = { ...next[idx], ...patchObj }
inner.value = next
sync()
}
function add() {
inner.value = [...inner.value, emptyLeg()]
sync()
}
function remove(idx) {
if (inner.value.length <= 1) return
inner.value = inner.value.filter((_, i) => i !== idx)
sync()
}
const pickerShow = ref(false)
const pickerColumns = ref([])
const editIdx = ref(0)
const editKey = ref('')
const dateShow = ref(false)
const dateValue = ref(['2026', '01', '01'])
function openSelect(idx, key, opts) {
editIdx.value = idx
editKey.value = key
pickerColumns.value = opts
pickerShow.value = true
}
function onPickerConfirm({ selectedOptions }) {
const v = selectedOptions?.[0]?.value ?? ''
patch(editIdx.value, { [editKey.value]: v })
pickerShow.value = false
}
function openDate(idx, key) {
editIdx.value = idx
editKey.value = key
const cur = String(inner.value[idx]?.[key] || '')
if (/^\d{4}-\d{2}-\d{2}$/.test(cur)) {
dateValue.value = cur.split('-')
} else {
const d = new Date()
dateValue.value = [
String(d.getFullYear()),
String(d.getMonth() + 1).padStart(2, '0'),
String(d.getDate()).padStart(2, '0')
]
}
dateShow.value = true
}
function onDateConfirm({ selectedValues }) {
const v = (selectedValues || dateValue.value).join('-')
patch(editIdx.value, { [editKey.value]: v })
dateShow.value = false
}
</script>
<style scoped>
.oa-trip {
padding: 4px 0 8px;
}
.oa-trip__leg {
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px dashed var(--border, #dcebe4);
}
.oa-trip__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 12px 8px;
font-size: 14px;
}
</style>
@@ -4,16 +4,20 @@
v-model="fileList"
:disabled="disabled"
:max-count="limit"
:accept="accept"
:capture="captureAttr"
:after-read="onAfterRead"
:before-delete="onBeforeDelete"
multiple
/>
<div v-if="!getToken()" class="oa-upload__tip">未携带登录 token,上传可能失败</div>
<div class="oa-upload__tip">
{{ tipText }}
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { showToast } from 'vant'
import { uploadAttachment } from '../../api/oa.js'
import { getToken } from '../../utils/auth.js'
@@ -24,10 +28,24 @@ const props = defineProps({
modelValue: { type: [Array, String, Object], default: () => [] },
disabled: { type: Boolean, default: false },
limit: { type: Number, default: 9 },
/** 默认同时允许图片与常见文件;图片在微信 web-view 可选「拍照 / 相册」 */
accept: { type: String, default: 'image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.zip,.rar' },
/**
* 不传/空:由系统选择器决定(微信内通常可选拍照或相册)
* 传 camera:仅调起相机
*/
capture: { type: String, default: '' },
formCreateInject: { type: Object, default: null }
})
const emit = defineEmits(['update:modelValue', 'change'])
const captureAttr = computed(() => (props.capture === 'camera' ? 'camera' : undefined))
const tipText = computed(() => {
if (!getToken()) return '未携带登录 token,上传可能失败'
if (props.capture === 'camera') return '将直接调起相机拍照上传'
return '图片可选择拍照或从相册选取(微信内 web-view 支持)'
})
function toUrlList(raw) {
if (raw == null || raw === '') return []
if (Array.isArray(raw)) {
@@ -115,7 +133,8 @@ function onBeforeDelete() {
}
.oa-upload__tip {
margin-top: 4px;
color: #cf8a19;
color: var(--text-muted, #6b7b74);
font-size: 12px;
line-height: 1.4;
}
</style>
+195 -16
View File
@@ -1,4 +1,31 @@
/** 按 zkxw-sc 已发布模板实际出现的 type 构造;非设计器理论全集 */
/** 按 zkxw-sc 已发布模板实际出现的 type 构造;套件带 children(与 Web 结构一致) */
const LEAVE_TYPE_OPTIONS = [
{ label: '事假', value: '事假' },
{ label: '调休', value: '调休' },
{ label: '病假', value: '病假' },
{ label: '年假', value: '年假' },
{ label: '产假', value: '产假' },
{ label: '陪产假', value: '陪产假' },
{ label: '婚假', value: '婚假' },
{ label: '丧假', value: '丧假' }
]
function emptyTripLeg() {
return {
transport: '',
tripType: '',
fromCity: '',
toCity: '',
startDate: '',
startHalf: '上午',
endDate: '',
endHalf: '下午',
duration: null,
durationManual: false
}
}
export const kitchenSinkRule = [
{ type: 'input', field: 'title', title: '单行文本', value: '', $required: true },
{ type: 'textarea', field: 'remark', title: '多行文本', value: '' },
@@ -69,31 +96,183 @@ export const kitchenSinkRule = [
value: [],
props: { multiple: true }
},
{ type: 'leaveKit', field: 'leaveKit', title: '请假套件 leaveKit', value: {} },
{
type: 'leaveKit',
field: 'leaveKitWrap',
title: '请假套件',
props: {
startField: 'leaveStart',
endField: 'leaveEnd',
durationField: 'leaveDuration',
typeField: 'leaveType'
},
children: [
{
type: 'select',
field: 'leaveType',
title: '请假类型',
$required: true,
options: LEAVE_TYPE_OPTIONS.map((o) => ({ ...o }))
},
{
type: 'datePicker',
field: 'leaveStart',
title: '开始时间',
$required: true,
props: { type: 'datetime' }
},
{
type: 'datePicker',
field: 'leaveEnd',
title: '结束时间',
$required: true,
props: { type: 'datetime' }
},
{
type: 'inputNumber',
field: 'leaveDuration',
title: '时长(h)',
$required: true,
props: { precision: 1, min: 0 }
}
]
},
{
type: 'overtimeKit',
field: 'overtimeKit',
title: '加班套件 overtimeKit',
value: {}
field: 'overtimeKitWrap',
title: '加班套件',
props: {
personField: 'otPerson',
startField: 'otStart',
endField: 'otEnd',
durationField: 'otDuration'
},
children: [
{
type: 'person',
field: 'otPerson',
title: '加班人',
$required: true,
props: { multiple: true }
},
{
type: 'datePicker',
field: 'otStart',
title: '开始时间',
$required: true,
props: { type: 'datetime' }
},
{
type: 'datePicker',
field: 'otEnd',
title: '结束时间',
$required: true,
props: { type: 'datetime' }
},
{
type: 'inputNumber',
field: 'otDuration',
title: '时长(h)',
$required: true,
props: { precision: 1, min: 0 }
}
]
},
{
type: 'confirmationKit',
field: 'confirmationKit',
title: '转正套件 confirmationKit',
value: {}
field: 'confirmKitWrap',
title: '转正套件',
children: [
{
type: 'datePicker',
field: 'hireDate',
title: '入职日期',
props: { type: 'date' }
},
{
type: 'inputNumber',
field: 'probationMonths',
title: '试用期(月)',
props: { min: 0 }
},
{
type: 'datePicker',
field: 'confirmationDate',
title: '转正日期',
props: { type: 'date' }
},
{
type: 'post',
field: 'confirmPost',
title: '职位',
props: { multiple: false }
},
{
type: 'dept',
field: 'confirmDept',
title: '部门',
props: { multiple: false }
}
]
},
{
type: 'resignationKit',
field: 'resignationKit',
title: '离职套件 resignationKit',
value: {}
field: 'resignKitWrap',
title: '离职套件',
children: [
{
type: 'datePicker',
field: 'resignHireDate',
title: '入职日期',
props: { type: 'date' }
},
{
type: 'datePicker',
field: 'lastWorkDay',
title: '最后工作日',
props: { type: 'date' }
},
{
type: 'input',
field: 'resignReason',
title: '离职原因',
props: { type: 'textarea', rows: 2 }
}
]
},
{ type: 'tripKit', field: 'tripKit', title: '差旅套件 tripKit', value: {} },
{
type: 'tripItinerary',
field: 'tripItinerary',
title: '行程 tripItinerary',
value: []
type: 'tripKit',
field: 'tripKitWrap',
title: '差旅套件',
children: [
{
type: 'input',
field: 'tripReason',
title: '出差事由',
$required: true,
props: { type: 'textarea', rows: 2 }
},
{
type: 'tripItinerary',
field: 'tripList',
title: '行程',
$required: true,
value: [emptyTripLeg()]
},
{
type: 'inputNumber',
field: 'tripDays',
title: '出差天数',
$required: true,
props: { precision: 1, step: 0.5, min: 0 }
},
{
type: 'person',
field: 'tripCompanions',
title: '同行人',
props: { multiple: true }
}
]
}
]
+11 -15
View File
@@ -1,4 +1,3 @@
import OaStubField from './components/OaStubField.vue'
import OaMoneyField from './components/OaMoneyField.vue'
import OaUploadField from './components/OaUploadField.vue'
import OaReceiveAccountSelect from './components/OaReceiveAccountSelect.vue'
@@ -8,8 +7,10 @@ import OaDeptSelect from './components/OaDeptSelect.vue'
import OaPostSelect from './components/OaPostSelect.vue'
import OaOwnerPersonSelect from './components/OaOwnerPersonSelect.vue'
import OaOwnerDeptSelect from './components/OaOwnerDeptSelect.vue'
import OaKitShell from './components/OaKitShell.vue'
import OaTripItinerary from './components/OaTripItinerary.vue'
/** P0 + P1 真实组件;套件仍用 stub */
/** 与 web-admin type 名对齐 */
const REAL_COMPONENTS = {
money: OaMoneyField,
upload: OaUploadField,
@@ -19,24 +20,19 @@ const REAL_COMPONENTS = {
dept: OaDeptSelect,
post: OaPostSelect,
ownerPerson: OaOwnerPersonSelect,
ownerDept: OaOwnerDeptSelect
ownerDept: OaOwnerDeptSelect,
leaveKit: OaKitShell,
overtimeKit: OaKitShell,
confirmationKit: OaKitShell,
resignationKit: OaKitShell,
tripKit: OaKitShell,
tripItinerary: OaTripItinerary
}
const STUB_TYPES = [
'leaveKit',
'overtimeKit',
'confirmationKit',
'resignationKit',
'tripKit',
'tripItinerary'
]
export function registerOaComponents(formCreate) {
if (!formCreate || typeof formCreate.component !== 'function') return
for (const [name, comp] of Object.entries(REAL_COMPONENTS)) {
formCreate.component(name, comp)
}
for (const name of STUB_TYPES) {
formCreate.component(name, OaStubField)
}
formCreate.component('fcUpload', OaUploadField)
}