FlowDesigner.vue 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134
  1. <!-- OA 竖向流程树设计器(钉钉式,无 X6) -->
  2. <template>
  3. <div class="oa-flow-designer">
  4. <p class="oa-flow-designer__hint">
  5. 点击节点配置选人;「+」插入审批 / 抄送 / 条件分支。预置角色(总经理 / 董事长)置顶。
  6. </p>
  7. <div class="oa-flow-designer__canvas">
  8. <div class="oa-flow-designer__canvas-inner">
  9. <FlowBranch
  10. :node="root"
  11. :candidates="candidates"
  12. @edit="openEdit"
  13. @remove="onRemove"
  14. @add="onAdd"
  15. />
  16. </div>
  17. </div>
  18. <el-drawer
  19. v-model="drawerVisible"
  20. size="640px"
  21. append-to-body
  22. class="oa-flow-drawer"
  23. :show-close="false"
  24. :close-on-click-modal="false"
  25. >
  26. <template #header>
  27. <div class="oa-flow-drawer__hd">
  28. <div class="oa-flow-drawer__hd-top">
  29. <div class="oa-flow-drawer__icon" aria-hidden="true">
  30. <el-icon :size="22"><UserFilled /></el-icon>
  31. </div>
  32. <div class="oa-flow-drawer__titles">
  33. <h3 class="oa-flow-drawer__title">{{ drawerTitle }}</h3>
  34. <p class="oa-flow-drawer__sub">{{ drawerSubtitle }}</p>
  35. </div>
  36. <button
  37. type="button"
  38. class="oa-flow-drawer__close"
  39. aria-label="关闭"
  40. @click="drawerVisible = false"
  41. >
  42. <el-icon :size="16"><Close /></el-icon>
  43. </button>
  44. </div>
  45. <div class="oa-flow-drawer__actions">
  46. <el-button class="btn-ghost" @click="drawerVisible = false">
  47. <el-icon><Close /></el-icon>
  48. 取消
  49. </el-button>
  50. <el-button class="btn-main" type="primary" @click="applyEdit">
  51. <el-icon><Check /></el-icon>
  52. 确定
  53. </el-button>
  54. </div>
  55. </div>
  56. </template>
  57. <el-form v-if="editing" label-position="top" class="oa-flow-drawer__form">
  58. <el-form-item label="节点名称">
  59. <el-input v-model="editing.name" maxlength="50" />
  60. </el-form-item>
  61. <template v-if="editing.type === 'approval' || editing.type === 'cc'">
  62. <el-form-item label="选人方式">
  63. <div class="assignee-seg">
  64. <button
  65. v-for="opt in assigneeTypeOptions"
  66. :key="opt.value"
  67. type="button"
  68. class="assignee-seg__item"
  69. :class="{
  70. 'is-on':
  71. editing.type === 'cc'
  72. ? (cfg.assigneeTypes || []).includes(opt.value)
  73. : cfg.assigneeType === opt.value
  74. }"
  75. @click="onAssigneeOptionClick(opt.value)"
  76. >
  77. {{ opt.label }}
  78. </button>
  79. </div>
  80. </el-form-item>
  81. <el-form-item v-if="showUsersPicker">
  82. <template #label>
  83. 指定成员
  84. <span class="form-label-tip">可多选,已选将作为本节点{{ editing.type === 'cc' ? '抄送' : '审批' }}人</span>
  85. </template>
  86. <div v-loading="userLoading" class="member-picker">
  87. <el-select
  88. v-model="memberPickBuffer"
  89. filterable
  90. clearable
  91. :loading="userLoading"
  92. placeholder="搜索姓名 / 工号,点击添加"
  93. style="width: 100%"
  94. popper-class="oa-flow-member-select-popper"
  95. @change="onMemberPick"
  96. >
  97. <el-option
  98. v-for="u in userOptions"
  99. :key="u.id"
  100. :label="memberOptionLabel(u)"
  101. :value="u.id"
  102. :disabled="(cfg.userIds || []).includes(u.id)"
  103. >
  104. <div class="member-option">
  105. <span class="member-avatar" :style="{ background: avatarColor(u.id) }">
  106. {{ avatarInitial(u.nickName) }}
  107. </span>
  108. <span class="member-option__meta">
  109. <span class="member-option__name">{{ u.nickName || '未命名' }}</span>
  110. <span v-if="memberOptionSub(u)" class="member-option__sub">
  111. {{ memberOptionSub(u) }}
  112. </span>
  113. </span>
  114. <el-tag
  115. v-if="(cfg.userIds || []).includes(u.id)"
  116. size="small"
  117. type="info"
  118. effect="plain"
  119. class="member-option__tag"
  120. >
  121. 已选
  122. </el-tag>
  123. </div>
  124. </el-option>
  125. </el-select>
  126. <div
  127. class="member-chips"
  128. :class="{ 'is-empty': !(cfg.userIds && cfg.userIds.length) }"
  129. >
  130. <template v-if="cfg.userIds && cfg.userIds.length">
  131. <span v-for="id in cfg.userIds" :key="id" class="member-chip">
  132. <span class="member-avatar member-avatar--sm" :style="{ background: avatarColor(id) }">
  133. {{ avatarInitial(resolveUser(id)?.nickName) }}
  134. </span>
  135. <span class="member-chip__name" :title="chipTitle(id)">
  136. {{ resolveUser(id)?.nickName || id }}
  137. </span>
  138. <button
  139. type="button"
  140. class="member-chip__rm"
  141. :aria-label="`移除 ${resolveUser(id)?.nickName || id}`"
  142. @click="removeMember(id)"
  143. >
  144. ×
  145. </button>
  146. </span>
  147. </template>
  148. <template v-else>暂未选择成员,请从上方搜索添加</template>
  149. </div>
  150. <p class="member-picked-count">已选 {{ cfg.userIds?.length || 0 }} 人</p>
  151. </div>
  152. </el-form-item>
  153. <el-form-item v-if="showRolesPicker" label="工作流角色">
  154. <el-select
  155. v-model="cfg.oaRoleIds"
  156. multiple
  157. filterable
  158. collapse-tags
  159. collapse-tags-tooltip
  160. placeholder="选择角色"
  161. style="width: 100%"
  162. :loading="roleLoading"
  163. >
  164. <el-option-group label="预置">
  165. <el-option
  166. v-for="r in presetRoles"
  167. :key="r.id"
  168. :label="`${r.roleName}(${r.roleCode})`"
  169. :value="r.id"
  170. />
  171. </el-option-group>
  172. <el-option-group label="自定义">
  173. <el-option
  174. v-for="r in customRoles"
  175. :key="r.id"
  176. :label="`${r.roleName}(${r.roleCode})`"
  177. :value="r.id"
  178. />
  179. </el-option-group>
  180. </el-select>
  181. </el-form-item>
  182. <el-form-item v-if="editing.type === 'approval'" label="多人审批方式">
  183. <div class="sign-mode">
  184. <label
  185. class="sign-mode__card"
  186. :class="{ 'is-on': cfg.signMode === 'or' }"
  187. >
  188. <el-radio v-model="cfg.signMode" value="or">
  189. <span class="sign-mode__text">
  190. <strong>或签</strong>
  191. <span>一人通过即可进入下一节点</span>
  192. </span>
  193. </el-radio>
  194. </label>
  195. <label
  196. class="sign-mode__card"
  197. :class="{ 'is-on': cfg.signMode === 'and' }"
  198. >
  199. <el-radio v-model="cfg.signMode" value="and">
  200. <span class="sign-mode__text">
  201. <strong>会签</strong>
  202. <span>所选成员全部通过后进入下一节点</span>
  203. </span>
  204. </el-radio>
  205. </label>
  206. </div>
  207. </el-form-item>
  208. </template>
  209. <template v-if="editing.type === 'condition'">
  210. <div
  211. v-for="(g, idx) in editing.conditionGroups || []"
  212. :key="idx"
  213. class="cond-group-editor"
  214. >
  215. <el-form-item :label="`分支 ${idx + 1} 名称`">
  216. <el-input v-model="g.name" />
  217. </el-form-item>
  218. <ConditionRuleEditor
  219. v-model="g.rules"
  220. :disabled="!!g.isDefault"
  221. :candidates="candidates"
  222. :legacy-when="g.when"
  223. />
  224. <el-form-item label="默认分支">
  225. <el-switch
  226. v-model="g.isDefault"
  227. @change="(v) => onDefaultChange(idx, !!v)"
  228. />
  229. </el-form-item>
  230. </div>
  231. <el-button class="btn-ghost" @click="addConditionGroup">
  232. <el-icon><Plus /></el-icon>
  233. 增加分支
  234. </el-button>
  235. </template>
  236. <template v-if="editing.type === 'start'">
  237. <p class="muted">发起范围在「基础设置」中配置;此处节点仅作流程起点。</p>
  238. </template>
  239. </el-form>
  240. </el-drawer>
  241. </div>
  242. </template>
  243. <script setup lang="ts">
  244. import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
  245. import { ElMessage, ElMessageBox } from 'element-plus'
  246. import { Plus, Close, Check, UserFilled } from '@element-plus/icons-vue'
  247. import { fetchUserList } from '@/api/admin'
  248. import { fetchOaRoleList } from '@/api/oaApproval'
  249. import { MAX_ADMIN_LIST_PAGE_SIZE } from '@/utils/fetch-paged-list'
  250. import type { ConditionCandidate } from '@/components/oa-approval/oa-condition-fields'
  251. import ConditionRuleEditor from './ConditionRuleEditor.vue'
  252. import FlowBranch from './FlowBranch.vue'
  253. import {
  254. createEndNode,
  255. createNode,
  256. ensureFlowRoot,
  257. findParent,
  258. normalizeCcAssigneeTypes,
  259. type ApproverConfig,
  260. type AssigneeType,
  261. type ConditionRule,
  262. type FlowNode
  263. } from './types'
  264. defineOptions({ name: 'OaFlowDesigner' })
  265. type UserOption = { id: string; nickName: string; userNo: string }
  266. const AVATAR_COLORS = [
  267. '#059669',
  268. '#0ea5e9',
  269. '#8b5cf6',
  270. '#d97706',
  271. '#ef4444',
  272. '#14b8a6',
  273. '#6366f1',
  274. '#ec4899'
  275. ]
  276. const props = withDefaults(
  277. defineProps<{
  278. modelValue: FlowNode | Record<string, unknown> | null
  279. candidates?: ConditionCandidate[]
  280. }>(),
  281. { candidates: () => [] }
  282. )
  283. const candidates = computed(() => props.candidates || [])
  284. const emit = defineEmits<{
  285. 'update:modelValue': [value: FlowNode]
  286. }>()
  287. const root = ref<FlowNode>(ensureFlowRoot(props.modelValue))
  288. watch(
  289. () => props.modelValue,
  290. (v) => {
  291. root.value = ensureFlowRoot(v)
  292. }
  293. )
  294. function commit() {
  295. emit('update:modelValue', root.value)
  296. }
  297. const drawerVisible = ref(false)
  298. const editing = ref<FlowNode | null>(null)
  299. const cfg = reactive<ApproverConfig>({
  300. assigneeType: 'dept_leader',
  301. assigneeTypes: [],
  302. userIds: [],
  303. oaRoleIds: [],
  304. signMode: 'or'
  305. })
  306. const assigneeTypeOptions: Array<{ label: string; value: AssigneeType }> = [
  307. { label: '指定成员', value: 'users' },
  308. { label: '工作流角色', value: 'oa_roles' },
  309. { label: '部门负责人', value: 'dept_leader' },
  310. { label: '发起人主管', value: 'direct_supervisor' },
  311. { label: '发起人自选', value: 'initiator_select' }
  312. ]
  313. const showUsersPicker = computed(() => {
  314. if (editing.value?.type === 'cc') return (cfg.assigneeTypes || []).includes('users')
  315. return cfg.assigneeType === 'users'
  316. })
  317. const showRolesPicker = computed(() => {
  318. if (editing.value?.type === 'cc') return (cfg.assigneeTypes || []).includes('oa_roles')
  319. return cfg.assigneeType === 'oa_roles'
  320. })
  321. const drawerTitle = computed(() => {
  322. const t = editing.value?.type
  323. if (t === 'start') return '发起人'
  324. if (t === 'approval') return '审批人设置'
  325. if (t === 'cc') return '抄送人设置'
  326. if (t === 'condition') return '条件分支'
  327. return '节点设置'
  328. })
  329. const drawerSubtitle = computed(() => {
  330. const t = editing.value?.type
  331. if (t === 'approval') return '配置本节点审批人与多人会签规则'
  332. if (t === 'cc') return '可同时选择多种方式,抄送时合并去重'
  333. if (t === 'condition') return '配置条件分支名称与可视化规则'
  334. if (t === 'start') return '流程起点节点'
  335. return '配置流程节点'
  336. })
  337. const roleLoading = ref(false)
  338. const userLoading = ref(false)
  339. const roleOptions = ref<any[]>([])
  340. const userOptions = ref<UserOption[]>([])
  341. const memberPickBuffer = ref('')
  342. const presetRoles = computed(() =>
  343. roleOptions.value.filter((r) => r.roleKind === 'preset' || ['GM', 'CHAIRMAN'].includes(r.roleCode))
  344. )
  345. const customRoles = computed(() =>
  346. roleOptions.value.filter((r) => r.roleKind !== 'preset' && !['GM', 'CHAIRMAN'].includes(r.roleCode))
  347. )
  348. function avatarColor(id: string) {
  349. let hash = 0
  350. for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0
  351. return AVATAR_COLORS[hash % AVATAR_COLORS.length]
  352. }
  353. function avatarInitial(name?: string) {
  354. const t = (name || '?').trim()
  355. return t ? t.slice(0, 1) : '?'
  356. }
  357. function resolveUser(id: string) {
  358. return userOptions.value.find((u) => u.id === id)
  359. }
  360. function chipTitle(id: string) {
  361. const u = resolveUser(id)
  362. return u ? memberOptionLabel(u) : id
  363. }
  364. function memberOptionLabel(u: UserOption) {
  365. const name = (u.nickName || '').trim() || '未命名'
  366. const no = (u.userNo || '').trim()
  367. if (no && no !== name) return `${name}(${no})`
  368. return name
  369. }
  370. /** 副文案:仅展示与姓名不同的账号,避免双行同名显得拥挤 */
  371. function memberOptionSub(u: UserOption) {
  372. const name = (u.nickName || '').trim()
  373. const no = (u.userNo || '').trim()
  374. if (!no || no === name) return ''
  375. return `账号 ${no}`
  376. }
  377. function onMemberPick(id: string) {
  378. if (!id) return
  379. if (!cfg.userIds) cfg.userIds = []
  380. if (!cfg.userIds.includes(id)) cfg.userIds.push(id)
  381. nextTick(() => {
  382. memberPickBuffer.value = ''
  383. })
  384. }
  385. function removeMember(id: string) {
  386. if (!cfg.userIds) return
  387. cfg.userIds = cfg.userIds.filter((x) => x !== id)
  388. }
  389. async function setAssigneeType(type: AssigneeType) {
  390. cfg.assigneeType = type
  391. await onAssigneeTypeChange()
  392. }
  393. async function onAssigneeOptionClick(type: AssigneeType) {
  394. if (editing.value?.type === 'cc') {
  395. const cur = [...(cfg.assigneeTypes || [])]
  396. const i = cur.indexOf(type)
  397. if (i >= 0) cur.splice(i, 1)
  398. else cur.push(type)
  399. cfg.assigneeTypes = cur
  400. if (cur[0]) cfg.assigneeType = cur[0]
  401. if (cur.includes('users')) await loadUsers()
  402. if (cur.includes('oa_roles')) await loadRoles()
  403. return
  404. }
  405. await setAssigneeType(type)
  406. }
  407. async function loadRoles() {
  408. roleLoading.value = true
  409. try {
  410. const res = await fetchOaRoleList({ status: '1' })
  411. roleOptions.value = res?.list || []
  412. } finally {
  413. roleLoading.value = false
  414. }
  415. }
  416. async function loadUsers() {
  417. userLoading.value = true
  418. try {
  419. const pageSize = MAX_ADMIN_LIST_PAGE_SIZE
  420. const raw: any[] = []
  421. let page = 1
  422. for (;;) {
  423. const res = await fetchUserList({ page, pageSize, status: '1' })
  424. const list = res?.list || []
  425. raw.push(...list)
  426. if (list.length < pageSize) break
  427. if (typeof res?.total === 'number' && raw.length >= res.total) break
  428. page += 1
  429. }
  430. userOptions.value = raw.map((u) => ({
  431. id: u.id,
  432. nickName: u.nickName || '',
  433. userNo: u.userNo || ''
  434. }))
  435. } finally {
  436. userLoading.value = false
  437. }
  438. }
  439. function findNode(node: FlowNode, id: string): FlowNode | null {
  440. if (node.id === id) return node
  441. if (node.child) {
  442. const f = findNode(node.child, id)
  443. if (f) return f
  444. }
  445. for (const g of node.conditionGroups || []) {
  446. if (g.child) {
  447. const f = findNode(g.child, id)
  448. if (f) return f
  449. }
  450. }
  451. return null
  452. }
  453. function onAdd(payload: {
  454. parentId: string
  455. type: 'approval' | 'cc' | 'condition'
  456. groupIndex?: number
  457. }) {
  458. const parent = findNode(root.value, payload.parentId)
  459. if (!parent) return
  460. const neu = createNode(payload.type)
  461. // 写入条件节点的某一分支
  462. if (
  463. parent.type === 'condition' &&
  464. typeof payload.groupIndex === 'number' &&
  465. parent.conditionGroups?.[payload.groupIndex]
  466. ) {
  467. const g = parent.conditionGroups[payload.groupIndex]
  468. const old = g.child || createEndNode()
  469. if (payload.type === 'condition') {
  470. const groups = neu.conditionGroups || []
  471. const def = groups.find((x) => x.isDefault) || groups[groups.length - 1]
  472. if (def) def.child = old
  473. } else {
  474. neu.child = old
  475. }
  476. g.child = neu
  477. commit()
  478. return
  479. }
  480. const old = parent.child || createEndNode()
  481. if (payload.type === 'condition') {
  482. const groups = neu.conditionGroups || []
  483. const def = groups.find((g) => g.isDefault) || groups[groups.length - 1]
  484. if (def) def.child = old
  485. parent.child = neu
  486. } else {
  487. neu.child = old.type === 'end' || old ? old : createEndNode()
  488. parent.child = neu
  489. }
  490. commit()
  491. }
  492. async function onRemove(nodeId: string) {
  493. const target = findNode(root.value, nodeId)
  494. if (!target || target.type === 'start' || target.type === 'end') {
  495. ElMessage.warning('该节点不可删除')
  496. return
  497. }
  498. await ElMessageBox.confirm(`确定删除节点「${target.name}」吗?`, '提示', { type: 'warning' })
  499. const parent = findParent(root.value, nodeId)
  500. if (!parent) return
  501. const splice = target.child || createEndNode()
  502. if (parent.type === 'condition') {
  503. for (const g of parent.conditionGroups || []) {
  504. if (g.child?.id === nodeId) {
  505. g.child = splice
  506. commit()
  507. return
  508. }
  509. }
  510. }
  511. if (parent.child?.id === nodeId) {
  512. parent.child = splice
  513. commit()
  514. return
  515. }
  516. ElMessage.warning('未能定位父节点,删除失败')
  517. }
  518. async function openEdit(node: FlowNode) {
  519. editing.value = JSON.parse(JSON.stringify(node)) as FlowNode
  520. if (editing.value.type === 'condition') {
  521. editing.value.conditionGroups = (editing.value.conditionGroups || []).map((g) => ({
  522. ...g,
  523. rules: Array.isArray(g.rules) ? g.rules : []
  524. }))
  525. }
  526. const ac = editing.value.approverConfig || { assigneeType: 'dept_leader' as const }
  527. if (editing.value.type === 'cc') {
  528. const types = normalizeCcAssigneeTypes(ac)
  529. cfg.assigneeTypes = types.length ? types : ['initiator_select']
  530. cfg.assigneeType = cfg.assigneeTypes[0]
  531. } else {
  532. cfg.assigneeTypes = []
  533. cfg.assigneeType = ac.assigneeType || 'dept_leader'
  534. }
  535. cfg.userIds = [...(ac.userIds || [])]
  536. cfg.oaRoleIds = [...(ac.oaRoleIds || [])]
  537. cfg.signMode = ac.signMode || 'or'
  538. memberPickBuffer.value = ''
  539. if (showUsersPicker.value) await loadUsers()
  540. if (showRolesPicker.value) await loadRoles()
  541. drawerVisible.value = true
  542. }
  543. async function onAssigneeTypeChange() {
  544. if (cfg.assigneeType === 'users' && !userOptions.value.length) await loadUsers()
  545. if (cfg.assigneeType === 'oa_roles' && !roleOptions.value.length) await loadRoles()
  546. }
  547. function onDefaultChange(idx: number, v: boolean) {
  548. if (!editing.value?.conditionGroups || !v) return
  549. editing.value.conditionGroups.forEach((g, i) => {
  550. g.isDefault = i === idx
  551. if (g.isDefault) {
  552. g.when = ''
  553. g.rules = []
  554. }
  555. })
  556. }
  557. function sanitizeConditionRules(rules?: ConditionRule[]): ConditionRule[] {
  558. return (rules || []).filter(
  559. (r) =>
  560. !!r &&
  561. String(r.field || '').trim() &&
  562. String(r.op || '').trim() &&
  563. r.value !== '' &&
  564. r.value != null
  565. )
  566. }
  567. function addConditionGroup() {
  568. if (!editing.value) return
  569. if (!editing.value.conditionGroups) editing.value.conditionGroups = []
  570. editing.value.conditionGroups.push({
  571. name: `条件${editing.value.conditionGroups.length}`,
  572. when: '',
  573. rules: [],
  574. isDefault: false,
  575. child: createEndNode()
  576. })
  577. }
  578. function applyEdit() {
  579. if (!editing.value) return
  580. const live = findNode(root.value, editing.value.id)
  581. if (!live) {
  582. drawerVisible.value = false
  583. return
  584. }
  585. live.name = editing.value.name
  586. if (live.type === 'cc') {
  587. const types = [...new Set(cfg.assigneeTypes || [])].filter(Boolean) as AssigneeType[]
  588. if (!types.length) {
  589. ElMessage.warning('请至少选择一种选人方式')
  590. return
  591. }
  592. if (types.includes('users') && !(cfg.userIds || []).length) {
  593. ElMessage.warning('已勾选指定成员,请选择至少一名成员')
  594. return
  595. }
  596. if (types.includes('oa_roles') && !(cfg.oaRoleIds || []).length) {
  597. ElMessage.warning('已勾选工作流角色,请选择至少一个角色')
  598. return
  599. }
  600. live.approverConfig = {
  601. assigneeTypes: types,
  602. assigneeType: types[0],
  603. userIds: [...(cfg.userIds || [])],
  604. oaRoleIds: [...(cfg.oaRoleIds || [])]
  605. }
  606. } else if (live.type === 'approval') {
  607. live.approverConfig = {
  608. assigneeType: cfg.assigneeType,
  609. userIds: cfg.assigneeType === 'users' ? [...(cfg.userIds || [])] : undefined,
  610. oaRoleIds: cfg.assigneeType === 'oa_roles' ? [...(cfg.oaRoleIds || [])] : undefined,
  611. signMode: cfg.signMode || 'or'
  612. }
  613. }
  614. if (live.type === 'condition' && editing.value.conditionGroups) {
  615. const oldGroups = live.conditionGroups || []
  616. live.conditionGroups = editing.value.conditionGroups.map((g, i) => {
  617. const isDefault = !!g.isDefault
  618. return {
  619. ...g,
  620. isDefault,
  621. when: '',
  622. rules: isDefault ? [] : sanitizeConditionRules(g.rules),
  623. child: oldGroups[i]?.child || g.child || createEndNode()
  624. }
  625. })
  626. }
  627. commit()
  628. drawerVisible.value = false
  629. }
  630. onMounted(() => {
  631. commit()
  632. })
  633. </script>
  634. <style scoped lang="scss">
  635. .oa-flow-designer {
  636. display: flex;
  637. flex-direction: column;
  638. width: 100%;
  639. height: calc(100vh - 200px);
  640. min-height: 480px;
  641. }
  642. .oa-flow-designer__hint {
  643. flex-shrink: 0;
  644. margin: 0 0 12px;
  645. color: var(--mint-text-muted, #94a3b8);
  646. font-size: 13px;
  647. }
  648. .oa-flow-designer__canvas {
  649. flex: 1 1 auto;
  650. width: 100%;
  651. min-width: 0;
  652. min-height: 0;
  653. overflow: auto;
  654. background: #f8fafc;
  655. border: 1px solid var(--mint-border, #e2e8f0);
  656. border-radius: 10px;
  657. }
  658. .oa-flow-designer__canvas-inner {
  659. display: flex;
  660. justify-content: center;
  661. box-sizing: border-box;
  662. width: max-content;
  663. min-width: 100%;
  664. padding: 24px 16px;
  665. }
  666. .oa-flow-drawer__form {
  667. :deep(.el-form-item) {
  668. margin-bottom: 18px;
  669. }
  670. :deep(.el-form-item__content) {
  671. width: 100%;
  672. }
  673. }
  674. .assignee-seg {
  675. display: grid;
  676. grid-template-columns: repeat(5, minmax(0, 1fr));
  677. gap: 10px;
  678. width: 100%;
  679. }
  680. .assignee-seg__item {
  681. box-sizing: border-box;
  682. width: 100%;
  683. min-width: 0;
  684. padding: 12px 6px;
  685. color: var(--mint-text-muted, #64748b);
  686. font: inherit;
  687. font-size: 12px;
  688. line-height: 1.35;
  689. text-align: center;
  690. white-space: nowrap;
  691. background: #fff;
  692. border: 1px solid var(--mint-border, #e2e8f0);
  693. border-radius: 8px;
  694. cursor: pointer;
  695. transition: 0.15s ease;
  696. &:hover {
  697. color: var(--mint-text, #0f172a);
  698. border-color: #a7f3d0;
  699. }
  700. &.is-on {
  701. color: var(--primary-dark, #059669);
  702. font-weight: 600;
  703. background: var(--primary-bg, #ecfdf5);
  704. border-color: var(--primary, #10b981);
  705. }
  706. }
  707. .member-picker {
  708. width: 100%;
  709. }
  710. .member-option {
  711. display: flex;
  712. gap: 12px;
  713. align-items: center;
  714. width: 100%;
  715. min-height: 40px;
  716. padding: 4px 0;
  717. }
  718. .member-option__meta {
  719. display: flex;
  720. flex: 1;
  721. flex-direction: column;
  722. gap: 2px;
  723. justify-content: center;
  724. min-width: 0;
  725. line-height: 1.35;
  726. }
  727. .member-option__name {
  728. overflow: hidden;
  729. font-size: 13px;
  730. font-weight: 600;
  731. white-space: nowrap;
  732. text-overflow: ellipsis;
  733. }
  734. .member-option__sub {
  735. overflow: hidden;
  736. color: var(--mint-text-muted, #64748b);
  737. font-size: 12px;
  738. white-space: nowrap;
  739. text-overflow: ellipsis;
  740. }
  741. .member-option__tag {
  742. flex-shrink: 0;
  743. margin-left: auto;
  744. }
  745. .member-avatar {
  746. display: inline-flex;
  747. flex-shrink: 0;
  748. align-items: center;
  749. justify-content: center;
  750. width: 36px;
  751. height: 36px;
  752. color: #fff;
  753. font-size: 13px;
  754. font-weight: 700;
  755. line-height: 1;
  756. text-align: center;
  757. border-radius: 50%;
  758. &--sm {
  759. width: 28px;
  760. height: 28px;
  761. font-size: 11px;
  762. }
  763. }
  764. .member-chips {
  765. display: flex;
  766. flex-wrap: wrap;
  767. gap: 8px;
  768. min-height: 44px;
  769. margin-top: 12px;
  770. padding: 12px;
  771. background: #f8fafc;
  772. border: 1px dashed #cbd5e1;
  773. border-radius: 8px;
  774. &.is-empty {
  775. align-items: center;
  776. justify-content: center;
  777. color: var(--mint-text-muted, #94a3b8);
  778. font-size: 13px;
  779. }
  780. }
  781. .member-chip {
  782. display: inline-flex;
  783. gap: 8px;
  784. align-items: center;
  785. max-width: 100%;
  786. padding: 4px 8px 4px 4px;
  787. background: #fff;
  788. border: 1px solid var(--mint-border, #e2e8f0);
  789. border-radius: 999px;
  790. }
  791. .member-chip__name {
  792. max-width: 120px;
  793. overflow: hidden;
  794. font-size: 13px;
  795. font-weight: 500;
  796. white-space: nowrap;
  797. text-overflow: ellipsis;
  798. }
  799. .member-chip__rm {
  800. display: grid;
  801. flex-shrink: 0;
  802. place-items: center;
  803. width: 20px;
  804. height: 20px;
  805. padding: 0;
  806. color: var(--mint-text-muted, #64748b);
  807. font-size: 14px;
  808. line-height: 1;
  809. background: #f1f5f9;
  810. border: 0;
  811. border-radius: 50%;
  812. cursor: pointer;
  813. &:hover {
  814. color: #dc2626;
  815. background: #fee2e2;
  816. }
  817. }
  818. .member-picked-count {
  819. margin: 8px 0 0;
  820. color: var(--mint-text-muted, #94a3b8);
  821. font-size: 12px;
  822. }
  823. .sign-mode {
  824. display: flex;
  825. flex-direction: column;
  826. gap: 8px;
  827. width: 100%;
  828. }
  829. .sign-mode__card {
  830. display: block;
  831. padding: 12px 14px;
  832. cursor: pointer;
  833. background: #fff;
  834. border: 1px solid var(--mint-border, #e2e8f0);
  835. border-radius: 8px;
  836. transition: 0.15s ease;
  837. &:hover {
  838. border-color: #a7f3d0;
  839. }
  840. &.is-on {
  841. background: var(--primary-bg, #ecfdf5);
  842. border-color: var(--primary, #10b981);
  843. }
  844. :deep(.el-radio) {
  845. width: 100%;
  846. height: auto;
  847. margin-right: 0;
  848. align-items: flex-start;
  849. white-space: normal;
  850. }
  851. :deep(.el-radio__label) {
  852. padding-left: 10px;
  853. }
  854. }
  855. .sign-mode__text {
  856. display: flex;
  857. flex-direction: column;
  858. gap: 2px;
  859. line-height: 1.4;
  860. strong {
  861. font-size: 13px;
  862. font-weight: 600;
  863. }
  864. span {
  865. color: var(--mint-text-muted, #64748b);
  866. font-size: 12px;
  867. font-weight: 400;
  868. }
  869. }
  870. .cond-group-editor {
  871. padding: 12px;
  872. margin-bottom: 12px;
  873. background: #f8fafc;
  874. border: 1px solid var(--mint-border, #e2e8f0);
  875. border-radius: 8px;
  876. }
  877. .muted {
  878. color: var(--mint-text-muted, #94a3b8);
  879. font-size: 13px;
  880. }
  881. </style>
  882. <!-- append-to-body 后需非 scoped,才能作用到抽屉 root -->
  883. <style lang="scss">
  884. .oa-flow-drawer {
  885. .el-drawer__header {
  886. margin-bottom: 0;
  887. padding: 0;
  888. }
  889. .el-drawer__body {
  890. padding: 20px 28px 28px;
  891. }
  892. .oa-flow-drawer__hd {
  893. padding: 18px 28px 14px;
  894. background: linear-gradient(180deg, #ecfdf5 0%, #fff 72%);
  895. border-bottom: 1px solid var(--mint-border, #e2e8f0);
  896. }
  897. .oa-flow-drawer__hd-top {
  898. display: flex;
  899. gap: 12px;
  900. align-items: flex-start;
  901. margin-bottom: 14px;
  902. }
  903. .oa-flow-drawer__icon {
  904. display: grid;
  905. flex-shrink: 0;
  906. place-items: center;
  907. width: 40px;
  908. height: 40px;
  909. color: #fff;
  910. background: var(--primary, #10b981);
  911. border-radius: 10px;
  912. box-shadow: 0 4px 12px rgba(16, 185, 129, 0.28);
  913. }
  914. .oa-flow-drawer__titles {
  915. flex: 1;
  916. min-width: 0;
  917. }
  918. .oa-flow-drawer__title {
  919. margin: 0;
  920. font-size: 16px;
  921. font-weight: 700;
  922. line-height: 1.35;
  923. letter-spacing: 0.01em;
  924. }
  925. .oa-flow-drawer__sub {
  926. margin: 2px 0 0;
  927. color: var(--mint-text-muted, #64748b);
  928. font-size: 12px;
  929. line-height: 1.4;
  930. }
  931. .oa-flow-drawer__close {
  932. display: grid;
  933. flex-shrink: 0;
  934. place-items: center;
  935. width: 32px;
  936. height: 32px;
  937. padding: 0;
  938. color: var(--mint-text-muted, #64748b);
  939. background: transparent;
  940. border: 0;
  941. border-radius: 8px;
  942. cursor: pointer;
  943. &:hover {
  944. color: var(--mint-text, #0f172a);
  945. background: #f1f5f9;
  946. }
  947. }
  948. .oa-flow-drawer__actions {
  949. display: flex;
  950. gap: 8px;
  951. justify-content: flex-end;
  952. }
  953. }
  954. /* 下拉挂 body,须非 scoped */
  955. .oa-flow-member-select-popper {
  956. .el-select-dropdown__list {
  957. padding: 8px;
  958. }
  959. .el-select-dropdown__item {
  960. height: auto !important;
  961. min-height: 56px;
  962. margin-bottom: 8px;
  963. padding: 10px 12px !important;
  964. line-height: normal !important;
  965. border-radius: 8px;
  966. &:last-child {
  967. margin-bottom: 0;
  968. }
  969. &.is-disabled {
  970. opacity: 0.55;
  971. }
  972. &.hover,
  973. &:hover {
  974. background: var(--primary-bg, #ecfdf5);
  975. }
  976. }
  977. .member-option {
  978. display: flex;
  979. gap: 12px;
  980. align-items: center;
  981. width: 100%;
  982. min-height: 36px;
  983. }
  984. .member-option__meta {
  985. display: flex;
  986. flex: 1;
  987. flex-direction: column;
  988. gap: 2px;
  989. justify-content: center;
  990. min-width: 0;
  991. }
  992. .member-option__name {
  993. overflow: hidden;
  994. color: var(--mint-text, #0f172a);
  995. font-size: 13px;
  996. font-weight: 600;
  997. white-space: nowrap;
  998. text-overflow: ellipsis;
  999. }
  1000. .member-option__sub {
  1001. overflow: hidden;
  1002. color: var(--mint-text-muted, #64748b);
  1003. font-size: 12px;
  1004. white-space: nowrap;
  1005. text-overflow: ellipsis;
  1006. }
  1007. .member-option__tag {
  1008. flex-shrink: 0;
  1009. margin-left: auto;
  1010. }
  1011. .member-avatar {
  1012. display: inline-flex;
  1013. flex-shrink: 0;
  1014. align-items: center;
  1015. justify-content: center;
  1016. width: 36px;
  1017. height: 36px;
  1018. color: #fff;
  1019. font-size: 13px;
  1020. font-weight: 700;
  1021. line-height: 1;
  1022. text-align: center;
  1023. border-radius: 50%;
  1024. }
  1025. }
  1026. </style>