提交
This commit is contained in:
1 parent
ac4c3d1886
commit
6a8c3e750e
21 files changed
+9885
-9150
No files matched your search
@@ -1,19 +1,14 @@
|
||||
<template>
|
||||
<div class="oa-icon-picker">
|
||||
<OaAppIcon :icon-url="modelValue" size="md" />
|
||||
<button
|
||||
type="button"
|
||||
class="oa-icon-picker__trigger"
|
||||
title="点击选择图标"
|
||||
@click="panelOpen = !panelOpen"
|
||||
>
|
||||
<OaAppIcon :icon-url="modelValue" size="md" />
|
||||
</button>
|
||||
<div class="oa-icon-picker__actions">
|
||||
<el-button class="btn-ghost" type="button" @click="panelOpen = !panelOpen">
|
||||
<el-icon><Grid /></el-icon>
|
||||
从图标库选择
|
||||
</el-button>
|
||||
<el-button class="btn-ghost" type="button" @click="onRecolor">
|
||||
<el-icon><Brush /></el-icon>
|
||||
换底色
|
||||
</el-button>
|
||||
<el-button class="btn-ghost" type="button" @click="onRandom">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
随机图标+底色
|
||||
</el-button>
|
||||
<el-upload
|
||||
:show-file-list="false"
|
||||
accept="image/*"
|
||||
@@ -25,13 +20,11 @@
|
||||
上传图片
|
||||
</el-button>
|
||||
</el-upload>
|
||||
<p class="oa-icon-picker__tip">
|
||||
新增时随机淡色底 + EP 图标(同色系深色);可展开选图标/换底色,或上传自定义图
|
||||
</p>
|
||||
<p class="oa-icon-picker__tip">点击左侧图标可展开图标库选择;也可上传自定义图片</p>
|
||||
</div>
|
||||
<div v-show="panelOpen" class="oa-icon-picker__panel">
|
||||
<div class="oa-icon-picker__panel-head">
|
||||
<strong>Element Plus 图标(精选)</strong>
|
||||
<strong>图标</strong>
|
||||
<el-button class="btn-ghost" style="height: 24px" @click="panelOpen = false">收起</el-button>
|
||||
</div>
|
||||
<div class="oa-icon-picker__grid">
|
||||
@@ -43,7 +36,7 @@
|
||||
:class="{ active: currentEp.icon === name && isEp }"
|
||||
@click="onPickIcon(name)"
|
||||
>
|
||||
<el-icon :style="{ color: currentEp.fg }"><component :is="epMap[name]" /></el-icon>
|
||||
<el-icon :size="22" :style="{ color: currentEp.fg }"><component :is="epMap[name]" /></el-icon>
|
||||
<span>{{ name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -72,21 +65,18 @@
|
||||
import {
|
||||
Bell,
|
||||
Briefcase,
|
||||
Brush,
|
||||
Calendar,
|
||||
Checked,
|
||||
Clock,
|
||||
CollectionTag,
|
||||
CreditCard,
|
||||
Document,
|
||||
Grid,
|
||||
Location,
|
||||
Message,
|
||||
Money,
|
||||
Notebook,
|
||||
OfficeBuilding,
|
||||
Present,
|
||||
Refresh,
|
||||
Setting,
|
||||
Stamp,
|
||||
Tickets,
|
||||
@@ -103,7 +93,6 @@
|
||||
buildEpIconUrl,
|
||||
isEpIconUrl,
|
||||
parseEpIconUrl,
|
||||
pickRandomOaAppIconUrl,
|
||||
recolorOaAppIconUrl,
|
||||
type OaAppEpIconName
|
||||
} from '@/utils/oa-app-icons'
|
||||
@@ -152,23 +141,6 @@
|
||||
setUrl(recolorOaAppIconUrl(props.modelValue, bg))
|
||||
}
|
||||
|
||||
function onRecolor() {
|
||||
const list = OA_APP_ICON_COLOR_PAIRS
|
||||
const cur = currentEp.value.bg
|
||||
let next = list[Math.floor(Math.random() * list.length)].bg
|
||||
if (list.length > 1) {
|
||||
let guard = 0
|
||||
while (next === cur && guard++ < 8) {
|
||||
next = list[Math.floor(Math.random() * list.length)].bg
|
||||
}
|
||||
}
|
||||
setUrl(recolorOaAppIconUrl(props.modelValue, next))
|
||||
}
|
||||
|
||||
function onRandom() {
|
||||
setUrl(pickRandomOaAppIconUrl())
|
||||
}
|
||||
|
||||
async function uploadIcon(options: UploadRequestOptions) {
|
||||
const file = options.file as File
|
||||
if (!file?.type?.startsWith('image/')) {
|
||||
@@ -204,6 +176,24 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.oa-icon-picker__trigger {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
outline: 2px solid #a7f3d0;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--primary, #10b981);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.oa-icon-picker__actions {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@@ -242,9 +232,9 @@
|
||||
|
||||
.oa-icon-picker__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||
gap: 8px;
|
||||
max-height: 220px;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@@ -254,15 +244,35 @@
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 56px;
|
||||
padding: 4px;
|
||||
height: 64px;
|
||||
padding: 6px 4px;
|
||||
color: #475569;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
background: #fff;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
|
||||
:deep(.el-icon) {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 22px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--primary-dark, #059669);
|
||||
border-color: #a7f3d0;
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
:model-value="rule.op"
|
||||
placeholder="条件"
|
||||
:disabled="disabled"
|
||||
class="cond-rule-editor__op"
|
||||
@update:model-value="(v) => onOpChange(idx, v)"
|
||||
>
|
||||
<el-option
|
||||
@@ -444,12 +445,24 @@
|
||||
|
||||
.cond-rule-editor__row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr auto;
|
||||
/* 字段 | 运算符(收窄)| 值(加宽)| 删除 */
|
||||
grid-template-columns: minmax(0, 1fr) 100px minmax(0, 1.8fr) auto;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.cond-rule-editor__row > .el-select,
|
||||
.cond-rule-editor__row > .el-tree-select,
|
||||
.cond-rule-editor__row > .el-input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cond-rule-editor__op {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cond-rule-editor__del {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<template>
|
||||
<div class="flow-branch">
|
||||
<template v-if="node.type === 'end'">
|
||||
<div class="flow-end">流程结束</div>
|
||||
<div class="flow-end">{{ endLabel }}</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
@@ -34,7 +34,14 @@
|
||||
class="flow-cond-col"
|
||||
>
|
||||
<div class="flow-cond-col__title" @click.stop="emit('edit', node)">
|
||||
{{ branchTitle(g, idx) }}
|
||||
<span class="flow-cond-col__name">{{ branchName(g, idx) }}</span>
|
||||
<span
|
||||
v-for="(line, lineIdx) in branchConditionLines(g)"
|
||||
:key="`${node.id}_g_${idx}_r_${lineIdx}`"
|
||||
class="muted"
|
||||
>
|
||||
{{ line }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- 分支仅有结束节点时,先露出「+」才能往分支内插入 -->
|
||||
<el-dropdown
|
||||
@@ -55,12 +62,14 @@
|
||||
v-if="g.child && g.child.type !== 'end'"
|
||||
:node="g.child"
|
||||
:depth="depth + 1"
|
||||
:end-label="branchEndLabel"
|
||||
:candidates="candidates"
|
||||
:value-label-maps="valueLabelMaps"
|
||||
@edit="(n) => emit('edit', n)"
|
||||
@remove="(id) => emit('remove', id)"
|
||||
@add="(payload) => emit('add', payload)"
|
||||
/>
|
||||
<div v-else class="flow-end">流程结束</div>
|
||||
<div v-else class="flow-end">{{ branchEndLabel }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -79,12 +88,14 @@
|
||||
v-if="node.child"
|
||||
:node="node.child"
|
||||
:depth="depth"
|
||||
:end-label="endLabel"
|
||||
:candidates="candidates"
|
||||
:value-label-maps="valueLabelMaps"
|
||||
@edit="(n) => emit('edit', n)"
|
||||
@remove="(id) => emit('remove', id)"
|
||||
@add="(payload) => emit('add', payload)"
|
||||
/>
|
||||
<div v-else class="flow-end">流程结束</div>
|
||||
<div v-else class="flow-end">{{ endLabel }}</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
@@ -103,12 +114,14 @@
|
||||
v-if="node.child"
|
||||
:node="node.child"
|
||||
:depth="depth"
|
||||
:end-label="endLabel"
|
||||
:candidates="candidates"
|
||||
:value-label-maps="valueLabelMaps"
|
||||
@edit="(n) => emit('edit', n)"
|
||||
@remove="(id) => emit('remove', id)"
|
||||
@add="(payload) => emit('add', payload)"
|
||||
/>
|
||||
<div v-else class="flow-end">流程结束</div>
|
||||
<div v-else class="flow-end">{{ endLabel }}</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
@@ -117,8 +130,9 @@
|
||||
<script setup lang="ts">
|
||||
import { Delete } from '@element-plus/icons-vue'
|
||||
import {
|
||||
formatConditionPreview,
|
||||
type ConditionCandidate
|
||||
formatConditionPreviewLines,
|
||||
type ConditionCandidate,
|
||||
type ConditionValueLabelMaps
|
||||
} from '@/components/oa-approval/oa-condition-fields'
|
||||
import type { ConditionGroup, FlowNode } from './types'
|
||||
import { summarizeApprover } from './types'
|
||||
@@ -131,9 +145,17 @@
|
||||
defineProps<{
|
||||
node: FlowNode
|
||||
depth?: number
|
||||
/** 分支/主链末端文案;有汇合后主链时分支内传「汇合」 */
|
||||
endLabel?: string
|
||||
candidates?: ConditionCandidate[]
|
||||
valueLabelMaps?: ConditionValueLabelMaps
|
||||
}>(),
|
||||
{ depth: 0, candidates: () => [] }
|
||||
{ depth: 0, endLabel: '流程结束', candidates: () => [], valueLabelMaps: () => ({}) }
|
||||
)
|
||||
|
||||
/** 本条件有汇合后 child,或外层已标明汇合时,分支列末端用「汇合」 */
|
||||
const branchEndLabel = computed(() =>
|
||||
props.node.child || props.endLabel === '汇合' ? '汇合' : props.endLabel
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -157,13 +179,20 @@
|
||||
return t
|
||||
}
|
||||
|
||||
function branchTitle(g: ConditionGroup, idx: number) {
|
||||
function branchName(g: ConditionGroup, idx: number) {
|
||||
if (g.isDefault) return g.name || '默认 · 其他情况'
|
||||
const preview = formatConditionPreview(g.rules || [], props.candidates || [])
|
||||
if (preview && preview !== '未配置') return preview
|
||||
return g.name || `条件${idx + 1}`
|
||||
}
|
||||
|
||||
function branchConditionLines(g: ConditionGroup) {
|
||||
if (g.isDefault) return [] as string[]
|
||||
return formatConditionPreviewLines(
|
||||
g.rules || [],
|
||||
props.candidates || [],
|
||||
props.valueLabelMaps
|
||||
)
|
||||
}
|
||||
|
||||
function nodeSummary(n: FlowNode) {
|
||||
if (n.type === 'start') return '谁可以发起本审批'
|
||||
if (n.type === 'condition') {
|
||||
@@ -245,9 +274,13 @@
|
||||
padding: 0;
|
||||
color: #fff !important;
|
||||
opacity: 0.85;
|
||||
background: transparent !important;
|
||||
|
||||
&:hover {
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: #ef4444 !important;
|
||||
opacity: 1;
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,15 +332,23 @@
|
||||
color: #5b21b6;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
background: #ede9fe;
|
||||
border-radius: 6px;
|
||||
|
||||
.flow-cond-col__name {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.muted {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: var(--mint-text-muted, #94a3b8);
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -9,6 +9,7 @@
|
||||
<FlowBranch
|
||||
:node="root"
|
||||
:candidates="candidates"
|
||||
:value-label-maps="conditionValueLabelMaps"
|
||||
@edit="openEdit"
|
||||
@remove="onRemove"
|
||||
@add="onAdd"
|
||||
@@ -298,9 +299,12 @@
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Close, Check, UserFilled } from '@element-plus/icons-vue'
|
||||
import { fetchUserList, fetchPostList } from '@/api/admin'
|
||||
import { fetchUserList, fetchPostList, fetchDeptTree } from '@/api/admin'
|
||||
import { MAX_ADMIN_LIST_PAGE_SIZE } from '@/utils/fetch-paged-list'
|
||||
import type { ConditionCandidate } from '@/components/oa-approval/oa-condition-fields'
|
||||
import type {
|
||||
ConditionCandidate,
|
||||
ConditionValueLabelMaps
|
||||
} from '@/components/oa-approval/oa-condition-fields'
|
||||
import ConditionRuleEditor from './ConditionRuleEditor.vue'
|
||||
import FlowBranch from './FlowBranch.vue'
|
||||
import {
|
||||
@@ -407,9 +411,30 @@
|
||||
type PostOption = { id: string; postName: string; postCode: string }
|
||||
const postOptions = ref<PostOption[]>([])
|
||||
const userOptions = ref<UserOption[]>([])
|
||||
const deptLabelMap = ref<Record<string, string>>({})
|
||||
const memberPickBuffer = ref('')
|
||||
const postPickBuffer = ref('')
|
||||
|
||||
const conditionValueLabelMaps = computed<ConditionValueLabelMaps>(() => {
|
||||
const users: Record<string, string> = {}
|
||||
for (const u of userOptions.value) {
|
||||
const id = String(u.id)
|
||||
const name = (u.nickName || '').trim() || (u.userNo || '').trim() || id
|
||||
users[id] = name
|
||||
}
|
||||
const posts: Record<string, string> = {}
|
||||
for (const p of postOptions.value) {
|
||||
const id = String(p.id)
|
||||
const name = (p.postName || '').trim() || (p.postCode || '').trim() || id
|
||||
posts[id] = name
|
||||
}
|
||||
return {
|
||||
users,
|
||||
posts,
|
||||
depts: { ...deptLabelMap.value }
|
||||
}
|
||||
})
|
||||
|
||||
function avatarColor(id: string) {
|
||||
let hash = 0
|
||||
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0
|
||||
@@ -557,6 +582,47 @@
|
||||
}
|
||||
}
|
||||
|
||||
function collectDeptLabels(nodes: Api.Dept.DeptItem[], out: Record<string, string>) {
|
||||
for (const n of nodes || []) {
|
||||
const id = String(n.id || '')
|
||||
if (id) out[id] = String(n.deptName || '').trim() || id
|
||||
if (n.children?.length) collectDeptLabels(n.children, out)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDeptLabels() {
|
||||
try {
|
||||
const tree = ((await fetchDeptTree()) || []) as Api.Dept.DeptItem[]
|
||||
const out: Record<string, string> = {}
|
||||
collectDeptLabels(tree, out)
|
||||
deptLabelMap.value = out
|
||||
} catch {
|
||||
deptLabelMap.value = {}
|
||||
}
|
||||
}
|
||||
|
||||
/** 画布条件分支标题:把人员/部门/职位 id 解析成名称 */
|
||||
async function ensureConditionPreviewLabels() {
|
||||
const types = new Set((candidates.value || []).map((c) => c.type))
|
||||
const tasks: Promise<void>[] = []
|
||||
if (
|
||||
[...types].some((t) => t === 'person' || t === 'ownerPerson' || t === 'initiator') &&
|
||||
!userOptions.value.length
|
||||
) {
|
||||
tasks.push(loadUsers())
|
||||
}
|
||||
if (types.has('post') && !postOptions.value.length) {
|
||||
tasks.push(loadPosts())
|
||||
}
|
||||
if (
|
||||
[...types].some((t) => t === 'dept' || t === 'ownerDept') &&
|
||||
!Object.keys(deptLabelMap.value).length
|
||||
) {
|
||||
tasks.push(loadDeptLabels())
|
||||
}
|
||||
if (tasks.length) await Promise.all(tasks)
|
||||
}
|
||||
|
||||
function findNode(node: FlowNode, id: string): FlowNode | null {
|
||||
if (node.id === id) return node
|
||||
if (node.child) {
|
||||
@@ -762,7 +828,19 @@
|
||||
|
||||
onMounted(() => {
|
||||
commit()
|
||||
void ensureConditionPreviewLabels()
|
||||
})
|
||||
|
||||
watch(
|
||||
() =>
|
||||
(candidates.value || [])
|
||||
.map((c) => c.type)
|
||||
.sort()
|
||||
.join(','),
|
||||
() => {
|
||||
void ensureConditionPreviewLabels()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -181,10 +181,54 @@ export function normalizeSchemaConditionRequired(schema: unknown): {
|
||||
}
|
||||
}
|
||||
|
||||
export function formatConditionPreview(
|
||||
rules: Array<{ field: string; op: string; value: unknown }>,
|
||||
candidates: ConditionCandidate[]
|
||||
/** 条件预览:人员/部门/职位 id → 展示名(存库仍为 id) */
|
||||
export interface ConditionValueLabelMaps {
|
||||
users?: Record<string, string>
|
||||
posts?: Record<string, string>
|
||||
depts?: Record<string, string>
|
||||
}
|
||||
|
||||
function formatConditionValueText(
|
||||
field: string,
|
||||
value: unknown,
|
||||
candidates: ConditionCandidate[],
|
||||
labelMaps?: ConditionValueLabelMaps
|
||||
): string {
|
||||
const cand = candidates.find((c) => c.field === field)
|
||||
const type = cand?.type || ''
|
||||
|
||||
const resolveOne = (raw: unknown): string => {
|
||||
if (raw == null || raw === '') return '?'
|
||||
const id = String(raw)
|
||||
if (type === 'person' || type === 'ownerPerson' || type === 'initiator') {
|
||||
return labelMaps?.users?.[id] || id
|
||||
}
|
||||
if (type === 'post') {
|
||||
return labelMaps?.posts?.[id] || id
|
||||
}
|
||||
if (type === 'dept' || type === 'ownerDept') {
|
||||
return labelMaps?.depts?.[id] || id
|
||||
}
|
||||
if (cand?.options?.length) {
|
||||
const opt = cand.options.find((o) => String(o.value) === id)
|
||||
if (opt) return opt.label
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (!value.length) return '(空)'
|
||||
return value.map(resolveOne).join('、')
|
||||
}
|
||||
if (value == null || value === '') return '?'
|
||||
return resolveOne(value)
|
||||
}
|
||||
|
||||
export function formatConditionPreviewLines(
|
||||
rules: Array<{ field: string; op: string; value: unknown }>,
|
||||
candidates: ConditionCandidate[],
|
||||
labelMaps?: ConditionValueLabelMaps
|
||||
): string[] {
|
||||
const titleOf = (f: string) => candidates.find((c) => c.field === f)?.title || f
|
||||
const opLabel: Record<string, string> = {
|
||||
'>': '大于',
|
||||
@@ -195,13 +239,19 @@ export function formatConditionPreview(
|
||||
'!=': '不等于',
|
||||
contains: '包含'
|
||||
}
|
||||
if (!rules?.length) return '未配置'
|
||||
return rules
|
||||
.map((r) => {
|
||||
const valueText = Array.isArray(r.value)
|
||||
? r.value.join('、')
|
||||
: (r.value ?? '?')
|
||||
return `${titleOf(r.field)} ${opLabel[r.op] || r.op} ${valueText}`
|
||||
})
|
||||
.join(' 且 ')
|
||||
if (!rules?.length) return []
|
||||
return rules.map((r) => {
|
||||
const valueText = formatConditionValueText(r.field, r.value, candidates, labelMaps)
|
||||
return `${titleOf(r.field)} ${opLabel[r.op] || r.op} ${valueText}`
|
||||
})
|
||||
}
|
||||
|
||||
export function formatConditionPreview(
|
||||
rules: Array<{ field: string; op: string; value: unknown }>,
|
||||
candidates: ConditionCandidate[],
|
||||
labelMaps?: ConditionValueLabelMaps
|
||||
): string {
|
||||
const lines = formatConditionPreviewLines(rules, candidates, labelMaps)
|
||||
if (!lines.length) return '未配置'
|
||||
return lines.join(' 且 ')
|
||||
}
|
||||
Reference in new issue
Block a user