This commit is contained in:
zlwu committed 2026-08-06 16:54:32 +08:00
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(' 且 ')
}