feat(mobile-oa-h5): implement P1 person/dept/post and owner field selects

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-11 12:19:14 +08:00
1 parent bb86eb7ad2
commit d5e61a1162
10 files changed
+832 -14

No files matched your search

@@ -20,6 +20,12 @@
- `receiveAccount`:底部多选弹层 + options API
- `relatedApproval`:关联列表 + 候选弹层(mine/done/cc)
## P1 真实组件(已落地)
- `person` / `dept` / `post`:底部选择弹层,值 `{ id, name }`(部门展示全路径)
- `ownerPerson` / `ownerDept`:单选封装,`allowModify` 控制可否改选
- 仍为 stub:各 Kit(请假/加班/转正/离职/差旅)
## 本地联调提示
1. `cd mobile-oa-h5 && npm run dev`
+12
View File
@@ -1,5 +1,17 @@
import { get, post, request } from '../utils/http.js'
export function fetchUserList(params) {
return get('/web/admin/users', params)
}
export function fetchDeptTree(params) {
return get('/web/admin/depts', params)
}
export function fetchPostList(params) {
return get('/web/admin/posts', params)
}
export function fetchReceiveAccountOptions() {
return get('/web/admin/oa-approval/receive-accounts/options')
}
@@ -0,0 +1,212 @@
<template>
<div class="oa-pick">
<van-field
:model-value="displayText"
readonly
is-link
:disabled="disabled"
placeholder="请选择部门"
@click="openPicker"
/>
<van-popup v-model:show="visible" position="bottom" round :style="{ height: '70%' }">
<div class="oa-pick__sheet">
<div class="oa-pick__hd">
<span @click="visible = false">取消</span>
<strong>选择部门</strong>
<span class="oa-pick__ok" @click="confirm">确定</span>
</div>
<van-search v-model="keyword" placeholder="搜索部门名称" />
<div class="oa-pick__list">
<template v-if="multiple">
<van-checkbox-group v-model="pendingIds">
<van-cell
v-for="d in filtered"
:key="d.id"
clickable
:title="d.label"
@click="toggle(d.id)"
>
<template #right-icon>
<van-checkbox :name="d.id" @click.stop />
</template>
</van-cell>
</van-checkbox-group>
</template>
<template v-else>
<van-radio-group v-model="pendingSingle">
<van-cell
v-for="d in filtered"
:key="d.id"
clickable
:title="d.label"
@click="pendingSingle = d.id"
>
<template #right-icon>
<van-radio :name="d.id" @click.stop />
</template>
</van-cell>
</van-radio-group>
</template>
<van-empty v-if="!loading && !filtered.length" description="暂无部门" />
</div>
</div>
</van-popup>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { showToast } from 'vant'
import { fetchDeptTree } from '../../api/oa.js'
import {
formatIdNameDisplay,
normalizeIdNameModelValue,
selectedIdsFromNormalized
} from '../../utils/id-name-value.js'
defineOptions({ name: 'dept' })
const SEP = ' / '
const props = defineProps({
modelValue: { type: [Array, Object], default: null },
disabled: { type: Boolean, default: false },
multiple: { type: Boolean, default: true },
max: { type: [Number, String], default: null },
formCreateInject: { type: Object, default: null }
})
const emit = defineEmits(['update:modelValue', 'change'])
const visible = ref(false)
const loading = ref(false)
const keyword = ref('')
const pendingIds = ref([])
const pendingSingle = ref('')
const nameMap = ref(new Map())
const flatOptions = ref([])
const multiple = computed(() => !!props.multiple)
const normalized = computed(() =>
normalizeIdNameModelValue(props.modelValue, multiple.value)
)
const displayText = computed(() => formatIdNameDisplay(normalized.value))
const filtered = computed(() => {
const q = keyword.value.trim().toLowerCase()
if (!q) return flatOptions.value
return flatOptions.value.filter((d) => d.label.toLowerCase().includes(q))
})
function flattenTree(nodes, ancestors = [], out = []) {
for (const n of nodes || []) {
const id = String(n.id || '').trim()
const name = String(n.deptName || n.name || id).trim()
if (!id) continue
const path = [...ancestors, name]
const label = path.join(SEP)
out.push({ id, label, name: label })
if (n.children?.length) flattenTree(n.children, path, out)
}
return out
}
function emitFromIds(ids) {
const items = []
const seen = new Set()
for (const id of ids) {
const tid = String(id || '').trim()
if (!tid || seen.has(tid)) continue
seen.add(tid)
items.push({ id: tid, name: nameMap.value.get(tid) || tid })
}
if (multiple.value) {
const max = props.max != null && Number(props.max) > 0 ? Number(props.max) : null
if (max != null && items.length > max) {
showToast(`最多可选 ${max} 个部门`)
return
}
emit('update:modelValue', items)
emit('change', items)
} else {
const one = items[0] || null
emit('update:modelValue', one)
emit('change', one)
}
}
function openPicker() {
if (props.disabled) return
const ids = selectedIdsFromNormalized(normalized.value, multiple.value)
if (multiple.value) pendingIds.value = ids
else pendingSingle.value = ids[0] || ''
keyword.value = ''
visible.value = true
if (!flatOptions.value.length) loadDepts()
}
function toggle(id) {
const set = new Set(pendingIds.value)
if (set.has(id)) set.delete(id)
else set.add(id)
pendingIds.value = [...set]
}
function confirm() {
emitFromIds(multiple.value ? pendingIds.value : pendingSingle.value ? [pendingSingle.value] : [])
visible.value = false
}
async function loadDepts() {
loading.value = true
try {
const tree = await fetchDeptTree()
const list = Array.isArray(tree) ? tree : tree?.list || []
const flat = flattenTree(list)
const map = new Map()
for (const d of flat) map.set(d.id, d.label)
const kept = Array.isArray(normalized.value)
? normalized.value
: normalized.value
? [normalized.value]
: []
for (const x of kept) {
if (!map.has(x.id) && x.name) map.set(x.id, x.name)
}
nameMap.value = map
flatOptions.value = flat
} catch (e) {
showToast(e?.message || '加载部门失败')
nameMap.value = new Map()
flatOptions.value = []
} finally {
loading.value = false
}
}
onMounted(() => {
loadDepts()
})
</script>
<style scoped>
.oa-pick__sheet {
display: flex;
flex-direction: column;
height: 100%;
background: #fff;
}
.oa-pick__hd {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--border, #dcebe4);
}
.oa-pick__ok {
color: var(--brand-green, #0a9f6d);
font-weight: 600;
}
.oa-pick__list {
flex: 1;
overflow: auto;
}
</style>
@@ -0,0 +1,34 @@
<template>
<OaDeptSelect
:model-value="modelValue"
:disabled="effectiveDisabled"
:multiple="false"
:form-create-inject="formCreateInject"
@update:model-value="onUpdate"
@change="onChange"
/>
</template>
<script setup>
import { computed } from 'vue'
import OaDeptSelect from './OaDeptSelect.vue'
defineOptions({ name: 'ownerDept' })
const props = defineProps({
modelValue: { type: [Array, Object], default: null },
disabled: { type: Boolean, default: false },
allowModify: { type: Boolean, default: false },
formCreateInject: { type: Object, default: null }
})
const emit = defineEmits(['update:modelValue', 'change'])
const effectiveDisabled = computed(() => !!props.disabled || !props.allowModify)
function onUpdate(v) {
emit('update:modelValue', v)
}
function onChange(v) {
emit('change', v)
}
</script>
@@ -0,0 +1,34 @@
<template>
<OaPersonSelect
:model-value="modelValue"
:disabled="effectiveDisabled"
:multiple="false"
:form-create-inject="formCreateInject"
@update:model-value="onUpdate"
@change="onChange"
/>
</template>
<script setup>
import { computed } from 'vue'
import OaPersonSelect from './OaPersonSelect.vue'
defineOptions({ name: 'ownerPerson' })
const props = defineProps({
modelValue: { type: [Array, Object], default: null },
disabled: { type: Boolean, default: false },
allowModify: { type: Boolean, default: false },
formCreateInject: { type: Object, default: null }
})
const emit = defineEmits(['update:modelValue', 'change'])
const effectiveDisabled = computed(() => !!props.disabled || !props.allowModify)
function onUpdate(v) {
emit('update:modelValue', v)
}
function onChange(v) {
emit('change', v)
}
</script>
@@ -0,0 +1,226 @@
<template>
<div class="oa-pick">
<van-field
:model-value="displayText"
readonly
is-link
:disabled="disabled"
placeholder="请选择人员"
@click="openPicker"
/>
<van-popup v-model:show="visible" position="bottom" round :style="{ height: '70%' }">
<div class="oa-pick__sheet">
<div class="oa-pick__hd">
<span @click="visible = false">取消</span>
<strong>选择人员</strong>
<span class="oa-pick__ok" @click="confirm">确定</span>
</div>
<van-search v-model="keyword" placeholder="搜索姓名或工号" />
<div class="oa-pick__list">
<template v-if="multiple">
<van-checkbox-group v-model="pendingIds">
<van-cell
v-for="u in filtered"
:key="u.id"
clickable
:title="u.label"
:label="u.sub || undefined"
@click="toggle(u.id)"
>
<template #right-icon>
<van-checkbox :name="u.id" @click.stop />
</template>
</van-cell>
</van-checkbox-group>
</template>
<template v-else>
<van-radio-group v-model="pendingSingle">
<van-cell
v-for="u in filtered"
:key="u.id"
clickable
:title="u.label"
:label="u.sub || undefined"
@click="pendingSingle = u.id"
>
<template #right-icon>
<van-radio :name="u.id" @click.stop />
</template>
</van-cell>
</van-radio-group>
</template>
<van-empty v-if="!loading && !filtered.length" description="暂无人员" />
</div>
</div>
</van-popup>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { showToast } from 'vant'
import { fetchUserList } from '../../api/oa.js'
import {
formatIdNameDisplay,
normalizeIdNameModelValue,
selectedIdsFromNormalized
} from '../../utils/id-name-value.js'
defineOptions({ name: 'person' })
const props = defineProps({
modelValue: { type: [Array, Object], default: null },
disabled: { type: Boolean, default: false },
multiple: { type: Boolean, default: true },
max: { type: [Number, String], default: null },
formCreateInject: { type: Object, default: null }
})
const emit = defineEmits(['update:modelValue', 'change'])
const visible = ref(false)
const loading = ref(false)
const keyword = ref('')
const pendingIds = ref([])
const pendingSingle = ref('')
const nameMap = ref(new Map())
const userOptions = ref([])
const multiple = computed(() => !!props.multiple)
const normalized = computed(() =>
normalizeIdNameModelValue(props.modelValue, multiple.value)
)
const displayText = computed(() => formatIdNameDisplay(normalized.value))
const filtered = computed(() => {
const q = keyword.value.trim().toLowerCase()
if (!q) return userOptions.value
return userOptions.value.filter(
(u) =>
u.label.toLowerCase().includes(q) ||
u.sub.toLowerCase().includes(q) ||
u.id.toLowerCase().includes(q)
)
})
function emitFromIds(ids) {
const items = []
const seen = new Set()
for (const id of ids) {
const tid = String(id || '').trim()
if (!tid || seen.has(tid)) continue
seen.add(tid)
items.push({ id: tid, name: nameMap.value.get(tid) || tid })
}
if (multiple.value) {
const max = props.max != null && Number(props.max) > 0 ? Number(props.max) : null
if (max != null && items.length > max) {
showToast(`最多可选 ${max} 个人员`)
return
}
emit('update:modelValue', items)
emit('change', items)
} else {
const one = items[0] || null
emit('update:modelValue', one)
emit('change', one)
}
}
function openPicker() {
if (props.disabled) return
const ids = selectedIdsFromNormalized(normalized.value, multiple.value)
if (multiple.value) pendingIds.value = ids
else pendingSingle.value = ids[0] || ''
keyword.value = ''
visible.value = true
if (!userOptions.value.length) loadUsers()
}
function toggle(id) {
const set = new Set(pendingIds.value)
if (set.has(id)) set.delete(id)
else set.add(id)
pendingIds.value = [...set]
}
function confirm() {
emitFromIds(multiple.value ? pendingIds.value : pendingSingle.value ? [pendingSingle.value] : [])
visible.value = false
}
async function loadUsers() {
loading.value = true
try {
const pageSize = 200
const raw = []
let page = 1
for (;;) {
const res = await fetchUserList({ page, pageSize, status: '1' })
const list = res?.list || []
raw.push(...list)
if (list.length < pageSize) break
if (typeof res?.total === 'number' && raw.length >= res.total) break
page += 1
if (page > 50) break
}
const map = new Map()
const opts = []
for (const u of raw) {
const id = String(u.id || '').trim()
if (!id) continue
const nick = String(u.nickName || '').trim()
const userNo = String(u.userNo || '').trim()
const label = nick || userNo || id
map.set(id, label)
opts.push({
id,
label,
sub: nick && userNo && nick !== userNo ? userNo : ''
})
}
const kept = Array.isArray(normalized.value)
? normalized.value
: normalized.value
? [normalized.value]
: []
for (const x of kept) {
if (!map.has(x.id) && x.name) map.set(x.id, x.name)
}
nameMap.value = map
userOptions.value = opts
} catch (e) {
showToast(e?.message || '加载人员失败')
nameMap.value = new Map()
userOptions.value = []
} finally {
loading.value = false
}
}
onMounted(() => {
loadUsers()
})
</script>
<style scoped>
.oa-pick__sheet {
display: flex;
flex-direction: column;
height: 100%;
background: #fff;
}
.oa-pick__hd {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--border, #dcebe4);
}
.oa-pick__ok {
color: var(--brand-green, #0a9f6d);
font-weight: 600;
}
.oa-pick__list {
flex: 1;
overflow: auto;
}
</style>
@@ -0,0 +1,222 @@
<template>
<div class="oa-pick">
<van-field
:model-value="displayText"
readonly
is-link
:disabled="disabled"
placeholder="请选择职位"
@click="openPicker"
/>
<van-popup v-model:show="visible" position="bottom" round :style="{ height: '70%' }">
<div class="oa-pick__sheet">
<div class="oa-pick__hd">
<span @click="visible = false">取消</span>
<strong>选择职位</strong>
<span class="oa-pick__ok" @click="confirm">确定</span>
</div>
<van-search v-model="keyword" placeholder="搜索职位名称或编码" />
<div class="oa-pick__list">
<template v-if="multiple">
<van-checkbox-group v-model="pendingIds">
<van-cell
v-for="p in filtered"
:key="p.id"
clickable
:title="p.label"
:label="p.sub || undefined"
@click="toggle(p.id)"
>
<template #right-icon>
<van-checkbox :name="p.id" @click.stop />
</template>
</van-cell>
</van-checkbox-group>
</template>
<template v-else>
<van-radio-group v-model="pendingSingle">
<van-cell
v-for="p in filtered"
:key="p.id"
clickable
:title="p.label"
:label="p.sub || undefined"
@click="pendingSingle = p.id"
>
<template #right-icon>
<van-radio :name="p.id" @click.stop />
</template>
</van-cell>
</van-radio-group>
</template>
<van-empty v-if="!loading && !filtered.length" description="暂无职位" />
</div>
</div>
</van-popup>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { showToast } from 'vant'
import { fetchPostList } from '../../api/oa.js'
import {
formatIdNameDisplay,
normalizeIdNameModelValue,
selectedIdsFromNormalized
} from '../../utils/id-name-value.js'
defineOptions({ name: 'post' })
const props = defineProps({
modelValue: { type: [Array, Object], default: null },
disabled: { type: Boolean, default: false },
multiple: { type: Boolean, default: true },
max: { type: [Number, String], default: null },
formCreateInject: { type: Object, default: null }
})
const emit = defineEmits(['update:modelValue', 'change'])
const visible = ref(false)
const loading = ref(false)
const keyword = ref('')
const pendingIds = ref([])
const pendingSingle = ref('')
const nameMap = ref(new Map())
const postOptions = ref([])
const multiple = computed(() => !!props.multiple)
const normalized = computed(() =>
normalizeIdNameModelValue(props.modelValue, multiple.value)
)
const displayText = computed(() => formatIdNameDisplay(normalized.value))
const filtered = computed(() => {
const q = keyword.value.trim().toLowerCase()
if (!q) return postOptions.value
return postOptions.value.filter(
(p) =>
p.label.toLowerCase().includes(q) ||
p.sub.toLowerCase().includes(q) ||
p.id.toLowerCase().includes(q)
)
})
function emitFromIds(ids) {
const items = []
const seen = new Set()
for (const id of ids) {
const tid = String(id || '').trim()
if (!tid || seen.has(tid)) continue
seen.add(tid)
items.push({ id: tid, name: nameMap.value.get(tid) || tid })
}
if (multiple.value) {
const max = props.max != null && Number(props.max) > 0 ? Number(props.max) : null
if (max != null && items.length > max) {
showToast(`最多可选 ${max} 个职位`)
return
}
emit('update:modelValue', items)
emit('change', items)
} else {
const one = items[0] || null
emit('update:modelValue', one)
emit('change', one)
}
}
function openPicker() {
if (props.disabled) return
const ids = selectedIdsFromNormalized(normalized.value, multiple.value)
if (multiple.value) pendingIds.value = ids
else pendingSingle.value = ids[0] || ''
keyword.value = ''
visible.value = true
if (!postOptions.value.length) loadPosts()
}
function toggle(id) {
const set = new Set(pendingIds.value)
if (set.has(id)) set.delete(id)
else set.add(id)
pendingIds.value = [...set]
}
function confirm() {
emitFromIds(multiple.value ? pendingIds.value : pendingSingle.value ? [pendingSingle.value] : [])
visible.value = false
}
async function loadPosts() {
loading.value = true
try {
const pageSize = 200
const raw = []
let page = 1
for (;;) {
const res = await fetchPostList({ page, pageSize })
const list = res?.list || []
raw.push(...list)
if (list.length < pageSize) break
if (typeof res?.total === 'number' && raw.length >= res.total) break
page += 1
if (page > 50) break
}
const map = new Map()
const opts = []
for (const p of raw) {
const id = String(p.id || '').trim()
if (!id) continue
const name = String(p.postName || '').trim()
const code = String(p.postCode || '').trim()
const label = name || code || id
map.set(id, label)
opts.push({ id, label, sub: name && code && name !== code ? code : '' })
}
const kept = Array.isArray(normalized.value)
? normalized.value
: normalized.value
? [normalized.value]
: []
for (const x of kept) {
if (!map.has(x.id) && x.name) map.set(x.id, x.name)
}
nameMap.value = map
postOptions.value = opts
} catch (e) {
showToast(e?.message || '加载职位失败')
nameMap.value = new Map()
postOptions.value = []
} finally {
loading.value = false
}
}
onMounted(() => {
loadPosts()
})
</script>
<style scoped>
.oa-pick__sheet {
display: flex;
flex-direction: column;
height: 100%;
background: #fff;
}
.oa-pick__hd {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--border, #dcebe4);
}
.oa-pick__ok {
color: var(--brand-green, #0a9f6d);
font-weight: 600;
}
.oa-pick__list {
flex: 1;
overflow: auto;
}
</style>
+27 -7
View File
@@ -34,21 +34,41 @@ export const kitchenSinkRule = [
title: '收款账户 receiveAccount',
value: []
},
{ type: 'dept', field: 'deptId', title: '部门 dept', value: '' },
{ type: 'person', field: 'personIds', title: '人员 person', value: [] },
{
type: 'dept',
field: 'deptId',
title: '部门 dept',
value: [],
props: { multiple: true }
},
{
type: 'person',
field: 'personIds',
title: '人员 person',
value: [],
props: { multiple: true }
},
{
type: 'ownerPerson',
field: 'ownerPerson',
title: '所属人 ownerPerson',
value: ''
title: '所属人 ownerPerson(可改选)',
value: null,
props: { allowModify: true }
},
{
type: 'ownerDept',
field: 'ownerDept',
title: '所属部门 ownerDept',
value: ''
title: '所属部门 ownerDept(可改选)',
value: null,
props: { allowModify: true }
},
{
type: 'post',
field: 'postId',
title: '岗位 post',
value: [],
props: { multiple: true }
},
{ type: 'post', field: 'postId', title: '岗位 post', value: '' },
{ type: 'leaveKit', field: 'leaveKit', title: '请假套件 leaveKit', value: {} },
{
type: 'overtimeKit',
+12 -7
View File
@@ -3,21 +3,26 @@ import OaMoneyField from './components/OaMoneyField.vue'
import OaUploadField from './components/OaUploadField.vue'
import OaReceiveAccountSelect from './components/OaReceiveAccountSelect.vue'
import OaRelatedApproval from './components/OaRelatedApproval.vue'
import OaPersonSelect from './components/OaPersonSelect.vue'
import OaDeptSelect from './components/OaDeptSelect.vue'
import OaPostSelect from './components/OaPostSelect.vue'
import OaOwnerPersonSelect from './components/OaOwnerPersonSelect.vue'
import OaOwnerDeptSelect from './components/OaOwnerDeptSelect.vue'
/** P0 真实组件;其余仍用 stub,阶段二继续替换 */
/** P0 + P1 真实组件;套件仍用 stub */
const REAL_COMPONENTS = {
money: OaMoneyField,
upload: OaUploadField,
receiveAccount: OaReceiveAccountSelect,
relatedApproval: OaRelatedApproval
relatedApproval: OaRelatedApproval,
person: OaPersonSelect,
dept: OaDeptSelect,
post: OaPostSelect,
ownerPerson: OaOwnerPersonSelect,
ownerDept: OaOwnerDeptSelect
}
const STUB_TYPES = [
'dept',
'person',
'ownerPerson',
'ownerDept',
'post',
'leaveKit',
'overtimeKit',
'confirmationKit',
+47
View File
@@ -0,0 +1,47 @@
/** person / dept / post 共用 { id, name } 值结构 */
export function isIdNameItem(v) {
if (!v || typeof v !== 'object') return false
return String(v.id || '').trim() !== '' && String(v.name || '').trim() !== ''
}
export function normalizeIdNameModelValue(raw, multiple) {
if (multiple) {
if (Array.isArray(raw)) {
return raw.filter(isIdNameItem).map((x) => ({
id: String(x.id).trim(),
name: String(x.name).trim()
}))
}
if (isIdNameItem(raw)) {
return [{ id: String(raw.id).trim(), name: String(raw.name).trim() }]
}
return []
}
if (isIdNameItem(raw)) {
return { id: String(raw.id).trim(), name: String(raw.name).trim() }
}
if (Array.isArray(raw) && raw.length === 1 && isIdNameItem(raw[0])) {
return { id: String(raw[0].id).trim(), name: String(raw[0].name).trim() }
}
return null
}
export function formatIdNameDisplay(raw) {
if (Array.isArray(raw)) {
return raw
.filter(isIdNameItem)
.map((x) => x.name)
.filter(Boolean)
.join('、')
}
if (isIdNameItem(raw)) return raw.name
return ''
}
export function selectedIdsFromNormalized(normalized, multiple) {
if (multiple) {
return Array.isArray(normalized) ? normalized.map((x) => x.id) : []
}
return normalized && !Array.isArray(normalized) ? [normalized.id] : []
}