OaFlowPreview.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513
  1. <!-- 发起审批 · 表单底部审批流预览(对齐 docs/mockups/oa-approval-start-flow-preview-demo.html) -->
  2. <template>
  3. <div v-loading="loading" class="oa-flow-preview">
  4. <div class="oa-flow-preview__title">
  5. <span>审批流程</span>
  6. <span v-if="resolved && hint" class="oa-flow-preview__hint">{{ hint }}</span>
  7. </div>
  8. <div v-if="!resolved" class="oa-flow-preview__pending">
  9. {{ pendingMessage || '必填信息填写后,流程将自动显示' }}
  10. </div>
  11. <div v-else class="oa-flow-preview__chain">
  12. <div v-if="!loading && !steps.length" class="oa-flow-preview__empty">暂无流程配置</div>
  13. <div v-for="(s, idx) in steps" :key="s.id || idx" class="oa-flow-step">
  14. <div class="oa-flow-rail">
  15. <div class="oa-flow-dot" :class="{ end: s.type === 'end' }" />
  16. <div v-if="idx < steps.length - 1" class="oa-flow-line" />
  17. </div>
  18. <div class="oa-flow-card">
  19. <div class="oa-flow-card__row">
  20. <span class="oa-flow-card__name">{{ s.name || '-' }}</span>
  21. <span v-if="signBadge(s)" class="oa-flow-badge" :class="signBadge(s)">
  22. {{ signBadgeText(s) }}
  23. </span>
  24. <template v-if="s.meta">
  25. <span class="oa-flow-card__sep">·</span>
  26. <span class="oa-flow-card__meta" :title="s.meta">{{ s.meta }}</span>
  27. </template>
  28. <span v-if="s.resolveError" class="oa-flow-card__err" :title="s.resolveError">
  29. {{ s.resolveError }}
  30. </span>
  31. <template v-if="isInitiatorSelect(s)">
  32. <span class="oa-flow-card__select">
  33. <span class="oa-flow-card__people">
  34. <template v-if="localPeople(s).length">
  35. <span v-for="p in localPeople(s)" :key="p.id" class="oa-person-chip">
  36. <span class="oa-person-chip__dot">{{ avatarChar(p.nickName) }}</span>
  37. {{ p.nickName }}
  38. <button
  39. type="button"
  40. class="oa-person-chip__x"
  41. title="移除"
  42. @click="removeUser(s.id, p.id)"
  43. >
  44. ×
  45. </button>
  46. </span>
  47. </template>
  48. <span v-else class="oa-flow-card__placeholder">
  49. {{ s.type === 'cc' ? '请选择抄送人' : '请选择审批人' }}
  50. </span>
  51. </span>
  52. <button type="button" class="oa-flow-add-btn" title="添加" @click="openPicker(s)">
  53. <el-icon><Plus /></el-icon>
  54. </button>
  55. </span>
  56. </template>
  57. <span v-else-if="(s.people || []).length" class="oa-flow-card__people">
  58. <span v-for="p in s.people" :key="p.id" class="oa-person-chip">
  59. <span class="oa-person-chip__dot">{{ avatarChar(p.nickName) }}</span>
  60. {{ p.nickName || p.id }}
  61. </span>
  62. </span>
  63. </div>
  64. </div>
  65. </div>
  66. </div>
  67. <el-dialog
  68. v-model="pickerVisible"
  69. :title="pickerTitle"
  70. width="520px"
  71. align-center
  72. append-to-body
  73. destroy-on-close
  74. class="dialog-form-layout"
  75. modal-class="protocol-dialog-mask"
  76. @closed="onPickerClosed"
  77. >
  78. <el-select
  79. v-model="pickerIds"
  80. multiple
  81. filterable
  82. clearable
  83. collapse-tags
  84. collapse-tags-tooltip
  85. placeholder="请选择人员"
  86. style="width: 100%"
  87. >
  88. <el-option
  89. v-for="u in userOptions"
  90. :key="u.id"
  91. :label="userOptionLabel(u)"
  92. :value="u.id"
  93. />
  94. </el-select>
  95. <template #footer>
  96. <div class="dialog-footer">
  97. <el-button @click="pickerVisible = false">
  98. <el-icon><Close /></el-icon>
  99. <span>取消</span>
  100. </el-button>
  101. <el-button type="primary" @click="confirmPicker">
  102. <el-icon><Check /></el-icon>
  103. <span>确定</span>
  104. </el-button>
  105. </div>
  106. </template>
  107. </el-dialog>
  108. </div>
  109. </template>
  110. <script setup lang="ts">
  111. import { computed, ref } from 'vue'
  112. import { Check, Close, Plus } from '@element-plus/icons-vue'
  113. import type { OaFlowPreviewStep } from '@/api/oaApproval'
  114. defineOptions({ name: 'OaFlowPreview' })
  115. type UserOption = { id: string; nickName: string; userNo?: string }
  116. const props = withDefaults(
  117. defineProps<{
  118. steps?: OaFlowPreviewStep[]
  119. loading?: boolean
  120. hint?: string
  121. resolved?: boolean
  122. pendingMessage?: string
  123. selectByNode?: Record<string, string[]>
  124. userOptions?: UserOption[]
  125. }>(),
  126. {
  127. steps: () => [],
  128. loading: false,
  129. hint: '按当前发起人解析审批人',
  130. resolved: true,
  131. pendingMessage: '必填信息填写后,流程将自动显示',
  132. selectByNode: () => ({}),
  133. userOptions: () => []
  134. }
  135. )
  136. const emit = defineEmits<{
  137. 'update:selectByNode': [Record<string, string[]>]
  138. 'select-change': []
  139. }>()
  140. const steps = computed(() => props.steps || [])
  141. const resolved = computed(() => props.resolved !== false)
  142. const userOptions = computed(() => props.userOptions || [])
  143. const pickerVisible = ref(false)
  144. const pickerNodeId = ref('')
  145. const pickerNodeType = ref('')
  146. const pickerIds = ref<string[]>([])
  147. const pickerTitle = computed(() =>
  148. pickerNodeType.value === 'cc' ? '选择抄送人' : '选择审批人'
  149. )
  150. function isInitiatorSelect(s: OaFlowPreviewStep) {
  151. return (
  152. s.assigneeType === 'initiator_select' && (s.type === 'approval' || s.type === 'cc')
  153. )
  154. }
  155. function localPeople(s: OaFlowPreviewStep) {
  156. const ids = (props.selectByNode || {})[s.id] || []
  157. const byId = new Map(userOptions.value.map((u) => [u.id, u]))
  158. return ids.map((id) => {
  159. const u = byId.get(id)
  160. return { id, nickName: u?.nickName || id }
  161. })
  162. }
  163. function userOptionLabel(u: UserOption) {
  164. const name = u.nickName || u.id
  165. return u.userNo ? `${name}(${u.userNo})` : name
  166. }
  167. function updateSelect(nodeId: string, ids: string[]) {
  168. const next: Record<string, string[]> = { ...(props.selectByNode || {}) }
  169. const uniq: string[] = []
  170. const seen = new Set<string>()
  171. for (const id of ids) {
  172. if (!id || seen.has(id)) continue
  173. seen.add(id)
  174. uniq.push(id)
  175. }
  176. if (uniq.length) next[nodeId] = uniq
  177. else delete next[nodeId]
  178. emit('update:selectByNode', next)
  179. emit('select-change')
  180. }
  181. function removeUser(nodeId: string, userId: string) {
  182. const cur = (props.selectByNode || {})[nodeId] || []
  183. updateSelect(
  184. nodeId,
  185. cur.filter((id) => id !== userId)
  186. )
  187. }
  188. function openPicker(s: OaFlowPreviewStep) {
  189. pickerNodeId.value = s.id
  190. pickerNodeType.value = s.type || ''
  191. pickerIds.value = [...((props.selectByNode || {})[s.id] || [])]
  192. pickerVisible.value = true
  193. }
  194. function confirmPicker() {
  195. if (pickerNodeId.value) {
  196. updateSelect(pickerNodeId.value, [...pickerIds.value])
  197. }
  198. pickerVisible.value = false
  199. }
  200. function onPickerClosed() {
  201. pickerNodeId.value = ''
  202. pickerNodeType.value = ''
  203. pickerIds.value = []
  204. }
  205. function signBadge(s: OaFlowPreviewStep) {
  206. if (s.type === 'cc' || s.signMode === 'cc') return 'cc'
  207. if (s.signMode === 'and') return 'and'
  208. if (s.signMode === 'or') return 'or'
  209. return ''
  210. }
  211. function signBadgeText(s: OaFlowPreviewStep) {
  212. const k = signBadge(s)
  213. if (k === 'cc') return '抄送'
  214. if (k === 'and') return '会签'
  215. if (k === 'or') return '或签'
  216. return ''
  217. }
  218. function avatarChar(name: string) {
  219. const n = (name || '').trim()
  220. return n ? n.slice(0, 1) : '?'
  221. }
  222. </script>
  223. <style scoped lang="scss">
  224. .oa-flow-preview {
  225. margin: 8px 0 12px;
  226. padding: 12px 12px 8px;
  227. background: #f8fafc;
  228. border: 1px solid var(--mint-border, #e2e8f0);
  229. border-radius: 10px;
  230. }
  231. .oa-flow-preview__title {
  232. display: flex;
  233. align-items: center;
  234. justify-content: space-between;
  235. margin-bottom: 10px;
  236. color: var(--mint-text, #334155);
  237. font-size: 13px;
  238. font-weight: 600;
  239. }
  240. .oa-flow-preview__hint {
  241. color: var(--mint-text-muted, #94a3b8);
  242. font-size: 12px;
  243. font-weight: 400;
  244. }
  245. .oa-flow-preview__pending {
  246. padding: 16px 8px 20px;
  247. color: var(--mint-text-muted, #94a3b8);
  248. font-size: 13px;
  249. line-height: 1.5;
  250. text-align: center;
  251. }
  252. .oa-flow-preview__empty {
  253. padding: 8px 0;
  254. color: var(--mint-text-muted, #94a3b8);
  255. font-size: 12px;
  256. }
  257. .oa-flow-preview__chain {
  258. min-height: 24px;
  259. }
  260. .oa-flow-step {
  261. display: flex;
  262. gap: 8px;
  263. align-items: stretch;
  264. }
  265. .oa-flow-rail {
  266. display: flex;
  267. flex-direction: column;
  268. align-items: center;
  269. width: 16px;
  270. flex-shrink: 0;
  271. }
  272. .oa-flow-dot {
  273. width: 10px;
  274. height: 10px;
  275. margin-top: 12px;
  276. background: #94a3b8;
  277. border-radius: 50%;
  278. box-shadow: 0 0 0 3px #e2e8f0;
  279. flex-shrink: 0;
  280. &.end {
  281. background: #cbd5e1;
  282. box-shadow: 0 0 0 3px #f1f5f9;
  283. }
  284. }
  285. .oa-flow-line {
  286. width: 2px;
  287. flex: 1;
  288. min-height: 10px;
  289. margin: 4px 0;
  290. background: #e2e8f0;
  291. }
  292. .oa-flow-card {
  293. flex: 1;
  294. min-width: 0;
  295. margin-bottom: 6px;
  296. padding: 9px 12px;
  297. background: #fff;
  298. border: 1px solid var(--mint-border, #e2e8f0);
  299. border-radius: 6px;
  300. }
  301. .oa-flow-card__row {
  302. display: flex;
  303. flex-wrap: wrap;
  304. gap: 6px 8px;
  305. align-items: center;
  306. min-width: 0;
  307. }
  308. .oa-flow-card__name {
  309. flex-shrink: 0;
  310. color: var(--mint-text-strong, #0f172a);
  311. font-size: 12px;
  312. font-weight: 600;
  313. line-height: 1.2;
  314. white-space: nowrap;
  315. }
  316. .oa-flow-card__meta {
  317. flex-shrink: 1;
  318. min-width: 0;
  319. overflow: hidden;
  320. color: var(--mint-text-muted, #94a3b8);
  321. font-size: 11px;
  322. line-height: 1.2;
  323. text-overflow: ellipsis;
  324. white-space: nowrap;
  325. }
  326. .oa-flow-card__sep {
  327. flex-shrink: 0;
  328. color: #cbd5e1;
  329. font-size: 11px;
  330. }
  331. .oa-flow-card__err {
  332. flex-shrink: 1;
  333. min-width: 0;
  334. overflow: hidden;
  335. color: #d97706;
  336. font-size: 11px;
  337. text-overflow: ellipsis;
  338. white-space: nowrap;
  339. }
  340. .oa-flow-card__select {
  341. display: inline-flex;
  342. flex: 1 1 140px;
  343. flex-wrap: wrap;
  344. gap: 6px;
  345. align-items: center;
  346. justify-content: flex-end;
  347. min-width: 0;
  348. margin-left: auto;
  349. }
  350. .oa-flow-card__people {
  351. display: inline-flex;
  352. flex: 1 1 140px;
  353. flex-wrap: wrap;
  354. gap: 4px;
  355. align-items: center;
  356. justify-content: flex-end;
  357. min-width: 0;
  358. margin-left: auto;
  359. }
  360. .oa-flow-card__select .oa-flow-card__people {
  361. flex: 0 1 auto;
  362. margin-left: 0;
  363. }
  364. .oa-flow-card__placeholder {
  365. color: var(--mint-text-muted, #94a3b8);
  366. font-size: 12px;
  367. line-height: 1.2;
  368. }
  369. .oa-flow-add-btn {
  370. display: inline-flex;
  371. flex-shrink: 0;
  372. align-items: center;
  373. justify-content: center;
  374. width: 28px;
  375. height: 28px;
  376. margin-left: 0;
  377. padding: 0;
  378. color: var(--mint-text, #334155);
  379. font-size: 14px;
  380. line-height: 1;
  381. cursor: pointer;
  382. background: #fff;
  383. border: 1px solid #e2e8f0;
  384. border-radius: 6px;
  385. &:hover {
  386. color: var(--primary, #10b981);
  387. border-color: #a7f3d0;
  388. background: var(--primary-bg, #ecfdf5);
  389. }
  390. }
  391. .oa-flow-card__select .oa-flow-add-btn {
  392. margin-left: 0;
  393. }
  394. .oa-person-chip {
  395. display: inline-flex;
  396. gap: 3px;
  397. align-items: center;
  398. padding: 1px 6px 1px 2px;
  399. color: var(--mint-text, #334155);
  400. font-size: 11px;
  401. line-height: 1.2;
  402. white-space: nowrap;
  403. background: #f1f5f9;
  404. border-radius: 999px;
  405. }
  406. .oa-person-chip__dot {
  407. display: grid;
  408. place-items: center;
  409. width: 16px;
  410. height: 16px;
  411. color: #fff;
  412. font-size: 9px;
  413. font-weight: 700;
  414. background: var(--primary, #10b981);
  415. border-radius: 50%;
  416. }
  417. .oa-person-chip__x {
  418. margin: 0;
  419. padding: 0 2px;
  420. color: var(--mint-text-muted, #94a3b8);
  421. font-size: 12px;
  422. line-height: 1;
  423. cursor: pointer;
  424. background: transparent;
  425. border: none;
  426. &:hover {
  427. color: #dc2626;
  428. }
  429. }
  430. .oa-flow-badge {
  431. flex-shrink: 0;
  432. padding: 0 5px;
  433. font-size: 10px;
  434. font-weight: 500;
  435. line-height: 16px;
  436. white-space: nowrap;
  437. border-radius: 4px;
  438. &.or {
  439. color: #d97706;
  440. background: #fff7ed;
  441. }
  442. &.and {
  443. color: #2563eb;
  444. background: #eff6ff;
  445. }
  446. &.cc {
  447. color: #0891b2;
  448. background: #ecfeff;
  449. }
  450. }
  451. .dialog-footer {
  452. display: flex;
  453. gap: 8px;
  454. justify-content: flex-end;
  455. .el-button .el-icon {
  456. margin-right: 6px;
  457. }
  458. }
  459. </style>