feat(oa): add dept select FormCreate control with print formatting
Register type=dept on main app and oa-fc-host, with PC tree-select and
narrow drawer pickers storing {id,name} path snapshots; format dept values
in print and related-summary flatten.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
45d84d9642
commit
2e6e1f47d6
9 files changed
+475
No files matched your search
@@ -104,6 +104,18 @@ def _format_value(typ: str, raw: Any) -> Tuple[str, bool]:
|
||||
else:
|
||||
n = 0
|
||||
return f"已关联 {n} 条", False
|
||||
if typ == "dept":
|
||||
if isinstance(raw, list):
|
||||
names = []
|
||||
for x in raw:
|
||||
if isinstance(x, dict):
|
||||
n = str(x.get("name") or "").strip()
|
||||
if n:
|
||||
names.append(n)
|
||||
return "、".join(names) if names else "", False
|
||||
if isinstance(raw, dict):
|
||||
return str(raw.get("name") or "").strip(), False
|
||||
return "", False
|
||||
if isinstance(raw, list):
|
||||
text = "、".join(str(x) for x in raw if x is not None and str(x) != "")
|
||||
elif isinstance(raw, dict):
|
||||
|
||||
@@ -37,6 +37,30 @@ class TestFlatten(unittest.TestCase):
|
||||
self.assertEqual(fields[2].get("relatedIds"), ["id1", "id2"])
|
||||
self.assertTrue(fields[3].get("fullWidth"))
|
||||
|
||||
def test_dept_formats_path_names(self):
|
||||
schema = {
|
||||
"rule": [
|
||||
{"type": "dept", "field": "dept", "title": "部门"},
|
||||
{
|
||||
"type": "dept",
|
||||
"field": "depts",
|
||||
"title": "多部门",
|
||||
"props": {"multiple": True},
|
||||
},
|
||||
]
|
||||
}
|
||||
data = {
|
||||
"dept": {"id": "d1", "name": "中科兴皖 / 研发"},
|
||||
"depts": [
|
||||
{"id": "d1", "name": "中科兴皖 / 研发"},
|
||||
{"id": "d2", "name": "中科兴皖 / 质量"},
|
||||
],
|
||||
}
|
||||
fields = flatten_form_fields_for_summary(schema, data)
|
||||
by_label = {f["label"]: f["value"] for f in fields}
|
||||
self.assertEqual(by_label["部门"], "中科兴皖 / 研发")
|
||||
self.assertEqual(by_label["多部门"], "中科兴皖 / 研发、中科兴皖 / 质量")
|
||||
|
||||
def test_related_approval_field_includes_related_ids(self):
|
||||
schema = {
|
||||
"rule": [
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
<!-- FormCreate 自定义控件:部门选择(Fresh Mint · PC 树选 + 窄屏抽屉) -->
|
||||
<template>
|
||||
<div class="oa-dept-select">
|
||||
<template v-if="disabled">
|
||||
<span class="oa-dept-select__readonly">{{ displayText || '—' }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="isNarrow">
|
||||
<button type="button" class="oa-dept-select__trigger" @click="openDrawer">
|
||||
<span v-if="displayText" class="oa-dept-select__trigger-text">{{ displayText }}</span>
|
||||
<span v-else class="oa-dept-select__placeholder">请选择部门</span>
|
||||
<span class="oa-dept-select__chev">▾</span>
|
||||
</button>
|
||||
<el-drawer
|
||||
v-model="drawerVisible"
|
||||
title="选择部门"
|
||||
direction="btt"
|
||||
size="75%"
|
||||
append-to-body
|
||||
:close-on-click-modal="false"
|
||||
class="oa-dept-drawer"
|
||||
modal-class="protocol-dialog-mask"
|
||||
>
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
clearable
|
||||
placeholder="搜索部门名称"
|
||||
class="oa-dept-select__search"
|
||||
/>
|
||||
<el-tree
|
||||
ref="treeRef"
|
||||
class="oa-dept-select__tree"
|
||||
:data="treeSelectData"
|
||||
node-key="value"
|
||||
:props="{ label: 'label', children: 'children' }"
|
||||
:filter-node-method="filterNode"
|
||||
:show-checkbox="multiple"
|
||||
:check-strictly="true"
|
||||
:highlight-current="!multiple"
|
||||
default-expand-all
|
||||
@node-click="onNarrowNodeClick"
|
||||
@check="onNarrowCheck"
|
||||
/>
|
||||
<template #footer>
|
||||
<el-button @click="drawerVisible = false">
|
||||
<el-icon><Close /></el-icon>
|
||||
取消
|
||||
</el-button>
|
||||
<el-button type="primary" @click="confirmDrawer">
|
||||
<el-icon><Check /></el-icon>
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<el-tree-select
|
||||
v-else
|
||||
:model-value="treeSelectModel"
|
||||
class="oa-dept-select__tree-select"
|
||||
:data="treeSelectData"
|
||||
:props="{ value: 'value', label: 'label', children: 'children' }"
|
||||
:multiple="multiple"
|
||||
:render-after-expand="false"
|
||||
filterable
|
||||
clearable
|
||||
check-strictly
|
||||
default-expand-all
|
||||
placeholder="请选择部门"
|
||||
style="width: 100%"
|
||||
@update:model-value="onTreeSelectChange"
|
||||
>
|
||||
<template #default="{ data }">
|
||||
<span>{{ data.label }}</span>
|
||||
</template>
|
||||
</el-tree-select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { Check, Close } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { fetchDeptTree } from '@/api/admin'
|
||||
import { buildPathLabelMapFromTreeRoots } from '@/utils/tree-path'
|
||||
import {
|
||||
formatDeptDisplay,
|
||||
normalizeDeptModelValue,
|
||||
type OaDeptValueItem
|
||||
} from '@/utils/oa-dept-value'
|
||||
|
||||
defineOptions({ name: 'dept' })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: unknown
|
||||
disabled?: boolean
|
||||
multiple?: boolean
|
||||
max?: number | null
|
||||
formCreateInject?: Record<string, unknown>
|
||||
}>(),
|
||||
{ multiple: true, max: null }
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: OaDeptValueItem | OaDeptValueItem[] | null): void
|
||||
(e: 'change', v: OaDeptValueItem | OaDeptValueItem[] | null): void
|
||||
}>()
|
||||
|
||||
type TreeNode = { value: string; label: string; children?: TreeNode[] }
|
||||
|
||||
const pathMap = ref(new Map<string, string>())
|
||||
const treeSelectData = ref<TreeNode[]>([])
|
||||
const isNarrow = ref(false)
|
||||
const drawerVisible = ref(false)
|
||||
const keyword = ref('')
|
||||
const pendingIds = ref<string[]>([])
|
||||
const treeRef = ref<{
|
||||
filter: (q: string) => void
|
||||
setCheckedKeys: (keys: string[]) => void
|
||||
setCurrentKey: (key: string | null) => void
|
||||
getCheckedKeys: () => string[]
|
||||
getCurrentKey: () => string | null
|
||||
} | null>(null)
|
||||
let mql: MediaQueryList | null = null
|
||||
|
||||
const normalized = computed(() => normalizeDeptModelValue(props.modelValue, !!props.multiple))
|
||||
|
||||
const displayText = computed(() => formatDeptDisplay(normalized.value))
|
||||
|
||||
const treeSelectModel = computed(() => {
|
||||
const n = normalized.value
|
||||
if (props.multiple) {
|
||||
return Array.isArray(n) ? n.map((x) => x.id) : []
|
||||
}
|
||||
return n && !Array.isArray(n) ? n.id : undefined
|
||||
})
|
||||
|
||||
function mapTree(nodes: Api.Dept.DeptItem[]): TreeNode[] {
|
||||
return (nodes || []).map((n) => ({
|
||||
value: n.id,
|
||||
label: n.deptName || n.id,
|
||||
children: n.children?.length ? mapTree(n.children) : undefined
|
||||
}))
|
||||
}
|
||||
|
||||
function emitFromIds(ids: string[]) {
|
||||
const items: OaDeptValueItem[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const id of ids) {
|
||||
const tid = String(id || '').trim()
|
||||
if (!tid || seen.has(tid)) continue
|
||||
seen.add(tid)
|
||||
const name = pathMap.value.get(tid) || tid
|
||||
items.push({ id: tid, name })
|
||||
}
|
||||
if (props.multiple) {
|
||||
const max = props.max != null && Number(props.max) > 0 ? Number(props.max) : null
|
||||
if (max != null && items.length > max) {
|
||||
ElMessage.warning(`最多可选 ${max} 个部门`)
|
||||
return
|
||||
}
|
||||
emit('update:modelValue', items)
|
||||
emit('change', items)
|
||||
} else {
|
||||
const one = items[0] || null
|
||||
emit('update:modelValue', one)
|
||||
emit('change', one)
|
||||
}
|
||||
}
|
||||
|
||||
function onTreeSelectChange(val: string | string[] | null | undefined) {
|
||||
if (props.multiple) {
|
||||
emitFromIds(Array.isArray(val) ? val.map(String) : [])
|
||||
} else {
|
||||
emitFromIds(val ? [String(val)] : [])
|
||||
}
|
||||
}
|
||||
|
||||
function openDrawer() {
|
||||
const n = normalized.value
|
||||
if (props.multiple) {
|
||||
pendingIds.value = Array.isArray(n) ? n.map((x) => x.id) : []
|
||||
} else {
|
||||
pendingIds.value = n && !Array.isArray(n) ? [n.id] : []
|
||||
}
|
||||
drawerVisible.value = true
|
||||
nextTick(() => {
|
||||
if (props.multiple) {
|
||||
treeRef.value?.setCheckedKeys(pendingIds.value)
|
||||
} else {
|
||||
treeRef.value?.setCurrentKey(pendingIds.value[0] || null)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onNarrowNodeClick(data: TreeNode) {
|
||||
if (props.multiple) return
|
||||
pendingIds.value = [data.value]
|
||||
treeRef.value?.setCurrentKey(data.value)
|
||||
}
|
||||
|
||||
function onNarrowCheck() {
|
||||
if (!props.multiple) return
|
||||
pendingIds.value = (treeRef.value?.getCheckedKeys() || []).map(String)
|
||||
}
|
||||
|
||||
function confirmDrawer() {
|
||||
let ids = pendingIds.value
|
||||
if (!props.multiple) {
|
||||
const cur = treeRef.value?.getCurrentKey()
|
||||
ids = cur ? [String(cur)] : []
|
||||
} else {
|
||||
ids = (treeRef.value?.getCheckedKeys() || []).map(String)
|
||||
}
|
||||
emitFromIds(ids)
|
||||
drawerVisible.value = false
|
||||
}
|
||||
|
||||
function filterNode(q: string, data: TreeNode) {
|
||||
if (!q) return true
|
||||
return (data.label || '').includes(q)
|
||||
}
|
||||
|
||||
watch(keyword, (q) => {
|
||||
treeRef.value?.filter(q || '')
|
||||
})
|
||||
|
||||
async function loadTree() {
|
||||
try {
|
||||
const tree = (await fetchDeptTree()) || []
|
||||
pathMap.value = buildPathLabelMapFromTreeRoots(tree, { nameKey: 'deptName' })
|
||||
treeSelectData.value = mapTree(tree)
|
||||
} catch {
|
||||
pathMap.value = new Map()
|
||||
treeSelectData.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function syncNarrow(e?: MediaQueryList | MediaQueryListEvent) {
|
||||
const matches = e && 'matches' in e ? e.matches : !!mql?.matches
|
||||
isNarrow.value = matches
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadTree()
|
||||
mql = window.matchMedia('(max-width: 767px)')
|
||||
syncNarrow(mql)
|
||||
mql.addEventListener('change', syncNarrow)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
mql?.removeEventListener('change', syncNarrow)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.oa-dept-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.oa-dept-select__readonly {
|
||||
color: var(--mint-text, #0f172a);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.oa-dept-select__trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--mint-border, #cbd5e1);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.oa-dept-select__placeholder {
|
||||
color: var(--mint-text-muted, #94a3b8);
|
||||
}
|
||||
|
||||
.oa-dept-select__trigger-text {
|
||||
color: var(--mint-text, #0f172a);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.oa-dept-select__chev {
|
||||
margin-left: 8px;
|
||||
color: var(--mint-text-muted, #94a3b8);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.oa-dept-select__search {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.oa-dept-select__tree {
|
||||
max-height: 50vh;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -17,6 +17,7 @@ import FcDesigner from '@form-create/designer'
|
||||
import formCreateAutoImport from '@form-create/element-ui/auto-import'
|
||||
import { setupFormCreateUpload } from '@/utils/oa-form-create-upload'
|
||||
import { setupOaRelatedApproval } from '@/utils/oa-form-create-related-approval'
|
||||
import { setupOaDeptSelect } from '@/utils/oa-form-create-dept'
|
||||
|
||||
// FormCreate 按字符串解析 el-input / fc-radio 等,主应用是按需自动导入,不会全局注册;
|
||||
// 必须挂上官方 auto-import,否则发起审批/详情里动态表单控件会残缺(如单选项只剩文字)。
|
||||
@@ -24,6 +25,7 @@ FcDesigner.formCreate.use(formCreateAutoImport)
|
||||
// 内置「上传」默认 action=/,改为走附件上传 API,避免 POST 前端原点 404
|
||||
setupFormCreateUpload(FcDesigner.formCreate)
|
||||
setupOaRelatedApproval(FcDesigner.formCreate)
|
||||
setupOaDeptSelect(FcDesigner.formCreate)
|
||||
|
||||
function maskSecret(value?: string) {
|
||||
if (!value) return '(empty)'
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'element-plus/dist/index.css'
|
||||
import FcDesigner from '@form-create/designer'
|
||||
import { setupFormCreateUpload } from '@/utils/oa-form-create-upload'
|
||||
import { setupOaRelatedApproval } from '@/utils/oa-form-create-related-approval'
|
||||
import { setupOaDeptSelect } from '@/utils/oa-form-create-dept'
|
||||
import App from './App.vue'
|
||||
|
||||
const app = createApp(App)
|
||||
@@ -22,4 +23,5 @@ app.use(FcDesigner)
|
||||
app.use(FcDesigner.formCreate)
|
||||
setupFormCreateUpload(FcDesigner.formCreate)
|
||||
setupOaRelatedApproval(FcDesigner.formCreate)
|
||||
setupOaDeptSelect(FcDesigner.formCreate)
|
||||
app.mount('#app')
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type RelatedPrintBlock
|
||||
} from '@/utils/oa-approval-print-related'
|
||||
import { formatProgressPersonLines } from '@/utils/oa-progress-person-lines'
|
||||
import { formatDeptDisplay } from '@/utils/oa-dept-value'
|
||||
|
||||
export const OA_PRINT_COMPANY_NAME = '安徽中科兴皖医学研究有限公司'
|
||||
|
||||
@@ -237,6 +238,12 @@ async function walkRules(
|
||||
continue
|
||||
}
|
||||
|
||||
if (type === 'dept') {
|
||||
const text = formatDeptDisplay(value)
|
||||
out.push({ label, text: text || '-' })
|
||||
continue
|
||||
}
|
||||
|
||||
if (UPLOAD_TYPES.has(type) || String(rule.name || '').toLowerCase().includes('upload')) {
|
||||
const urls = extractUrls(value)
|
||||
out.push({ label, urls: urls.length ? urls : [], text: urls.length ? undefined : '-' })
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/** 与 FormCreate 官方 unique 同类:生成不重复 field */
|
||||
let _uidSeq = 0
|
||||
function uniqueId() {
|
||||
const num = 370 + ++_uidSeq
|
||||
return (
|
||||
'F' +
|
||||
Math.random().toString(36).slice(3, 6) +
|
||||
Number(`${Date.now()}`).toString(36) +
|
||||
num.toString(36) +
|
||||
'c'
|
||||
)
|
||||
}
|
||||
|
||||
/** FormCreate Designer DragRule:部门选择 */
|
||||
export const deptSelectDragRule = {
|
||||
menu: 'main' as const,
|
||||
icon: 'icon-tree',
|
||||
label: '部门',
|
||||
name: 'dept',
|
||||
input: true,
|
||||
mask: true,
|
||||
validate: ['string', 'array', 'object'],
|
||||
event: ['change'],
|
||||
rule() {
|
||||
return {
|
||||
type: 'dept',
|
||||
field: uniqueId(),
|
||||
title: '部门',
|
||||
info: '',
|
||||
$required: false,
|
||||
props: {
|
||||
multiple: true,
|
||||
max: undefined as number | undefined
|
||||
}
|
||||
}
|
||||
},
|
||||
props() {
|
||||
return [
|
||||
{
|
||||
type: 'switch',
|
||||
field: 'multiple',
|
||||
title: '允许多选',
|
||||
value: true,
|
||||
props: { activeText: '是', inactiveText: '否' }
|
||||
},
|
||||
{
|
||||
type: 'inputNumber',
|
||||
field: 'max',
|
||||
title: '最大可选数',
|
||||
value: undefined,
|
||||
props: { min: 1, precision: 0, placeholder: '不填则不限制' }
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
field: 'formCreateInfo',
|
||||
title: '提示信息'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export type OaDeptValueItem = { id: string; name: string }
|
||||
|
||||
export function isDeptValueItem(v: unknown): v is OaDeptValueItem {
|
||||
if (!v || typeof v !== 'object') return false
|
||||
const o = v as Record<string, unknown>
|
||||
return String(o.id || '').trim() !== '' && String(o.name || '').trim() !== ''
|
||||
}
|
||||
|
||||
/** 单选 → item|null;多选 → items(容错把单对象包成数组) */
|
||||
export function normalizeDeptModelValue(
|
||||
raw: unknown,
|
||||
multiple: boolean
|
||||
): OaDeptValueItem | OaDeptValueItem[] | null {
|
||||
if (multiple) {
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.filter(isDeptValueItem).map((x) => ({
|
||||
id: String(x.id).trim(),
|
||||
name: String(x.name).trim()
|
||||
}))
|
||||
}
|
||||
if (isDeptValueItem(raw)) {
|
||||
return [{ id: String(raw.id).trim(), name: String(raw.name).trim() }]
|
||||
}
|
||||
return []
|
||||
}
|
||||
if (isDeptValueItem(raw)) {
|
||||
return { id: String(raw.id).trim(), name: String(raw.name).trim() }
|
||||
}
|
||||
if (Array.isArray(raw) && raw.length === 1 && isDeptValueItem(raw[0])) {
|
||||
return { id: String(raw[0].id).trim(), name: String(raw[0].name).trim() }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function formatDeptDisplay(raw: unknown): string {
|
||||
if (Array.isArray(raw)) {
|
||||
return raw
|
||||
.filter(isDeptValueItem)
|
||||
.map((x) => x.name)
|
||||
.filter(Boolean)
|
||||
.join('、')
|
||||
}
|
||||
if (isDeptValueItem(raw)) return raw.name
|
||||
return ''
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import FcDesigner from '@form-create/designer'
|
||||
import OaDeptSelect from '@/components/oa-approval/OaDeptSelect.vue'
|
||||
import { deptSelectDragRule } from '@/utils/oa-dept-select-rule'
|
||||
|
||||
const FLAG = '__oaDeptSelectConfigured'
|
||||
|
||||
export function setupOaDeptSelect(formCreate?: { component?: (name: string, c: unknown) => void }) {
|
||||
if ((FcDesigner as any)[FLAG]) return
|
||||
FcDesigner.component('dept', OaDeptSelect)
|
||||
if (typeof formCreate?.component === 'function') {
|
||||
formCreate.component('dept', OaDeptSelect)
|
||||
}
|
||||
if (typeof FcDesigner.addDragRule === 'function') {
|
||||
FcDesigner.addDragRule(deptSelectDragRule)
|
||||
}
|
||||
;(FcDesigner as any)[FLAG] = true
|
||||
}
|
||||
Reference in new issue
Block a user