bx-wf-config-page.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465
  1. <template>
  2. <view class="bx-page">
  3. <view class="topbar" :style="safeAreaStyle">
  4. <view class="topbar-left">
  5. <text class="topbar-back" @click="onBack">←</text>
  6. <text class="topbar-title">{{ pageTitle }}</text>
  7. </view>
  8. <view class="topbar-right" />
  9. </view>
  10. <view class="fab-add" @click="openAddDialog">+</view>
  11. <view class="page-padded">
  12. <view class="detail-grid" style="margin-top: 12px">
  13. <view class="detail-item">
  14. <view class="di-label">流程名称</view>
  15. <view class="di-value">{{ wfDef.wfName || '-' }}</view>
  16. </view>
  17. <view class="detail-item">
  18. <view class="di-label">状态</view>
  19. <view class="di-value">
  20. <text class="status-tag" :class="adminStatusTagClass(wfDef.status)">
  21. {{ statusLabel(wfDef.status) }}
  22. </text>
  23. </view>
  24. </view>
  25. </view>
  26. <view v-if="loading" class="page-body-loading">
  27. <text>加载中...</text>
  28. </view>
  29. <template v-if="!loading">
  30. <view v-if="nodes.length" class="section-title">审批节点</view>
  31. <view v-if="nodes.length" class="flow-card">
  32. <view
  33. v-for="(node, index) in nodes"
  34. :key="node.id ?? index"
  35. class="flow-step"
  36. >
  37. <view class="flow-avatar av-done">{{ index + 1 }}</view>
  38. <view class="flow-info">
  39. <view class="flow-info__head">
  40. <text class="flow-name">{{ node.nodeName }}</text>
  41. <text
  42. class="status-tag"
  43. :class="
  44. node.approverId || node.approver_id ? 's-approved' : 's-pending'
  45. "
  46. >
  47. {{ node.approverId || node.approver_id ? '已配置' : '未配置' }}
  48. </text>
  49. </view>
  50. <view class="flow-role"
  51. >审批人:{{ node.approverName ?? node.approver_name ?? '-' }}</view
  52. >
  53. <view class="flow-time">节点顺序:{{ node.nodeOrder }}</view>
  54. <view class="op-actions" style="margin-top: 8px">
  55. <button class="btn btn-sm btn-primary" @click="openEditDialog(node)">
  56. 编辑
  57. </button>
  58. <button
  59. class="btn btn-sm btn-danger"
  60. :disabled="!!loadingMap[`delete-${nodeKey(node)}`]"
  61. @click="handleDelete(node)"
  62. >
  63. {{ loadingMap[`delete-${nodeKey(node)}`] ? '删除中...' : '删除' }}
  64. </button>
  65. <button
  66. class="btn btn-sm btn-secondary"
  67. :disabled="index === 0 || !!loadingMap[`up-${nodeKey(node)}`]"
  68. @click="moveUp(node)"
  69. >
  70. {{ loadingMap[`up-${nodeKey(node)}`] ? '...' : '上移' }}
  71. </button>
  72. <button
  73. class="btn btn-sm btn-secondary"
  74. :disabled="
  75. index === nodes.length - 1 || !!loadingMap[`down-${nodeKey(node)}`]
  76. "
  77. @click="moveDown(node)"
  78. >
  79. {{ loadingMap[`down-${nodeKey(node)}`] ? '...' : '下移' }}
  80. </button>
  81. </view>
  82. </view>
  83. </view>
  84. </view>
  85. <view v-if="!nodes.length" class="empty-state empty-state--fill">
  86. <text>暂无流程节点,点击右下角新增</text>
  87. </view>
  88. </template>
  89. </view>
  90. <!-- 节点表单 -->
  91. <view v-if="showForm" class="bx-sheet-mask" @click="showForm = false">
  92. <view class="bx-sheet" @click.stop>
  93. <view class="bx-sheet__title">{{ isEdit ? '编辑节点' : '新增节点' }}</view>
  94. <view class="bx-sheet__body bx-sheet__body--form">
  95. <view class="form-card" style="margin: 0; border: none; box-shadow: none">
  96. <view class="field">
  97. <text class="field-label">节点名称<span class="req">*</span></text>
  98. <input
  99. v-model="formData.nodeName"
  100. class="form-input"
  101. placeholder="请输入节点名称"
  102. />
  103. </view>
  104. <view class="field">
  105. <text class="field-label">审批人</text>
  106. <view class="picker-trigger" @click="openApproverPicker">
  107. <text :class="approverDisplay ? 'value' : 'ph'">{{
  108. approverDisplay || '请选择审批人'
  109. }}</text>
  110. <text class="arrow">›</text>
  111. </view>
  112. <view v-if="!userOptions.length && approverFallback" class="field-tip">
  113. 用户列表不可用时,可手动填写审批人 ID / 姓名
  114. </view>
  115. <view v-if="!userOptions.length && approverFallback" class="field" style="margin-top: 10px">
  116. <input
  117. v-model="formData.approverId"
  118. class="form-input"
  119. placeholder="审批人 ID(可选)"
  120. />
  121. </view>
  122. <view v-if="!userOptions.length && approverFallback" class="field">
  123. <input
  124. v-model="formData.approverName"
  125. class="form-input"
  126. placeholder="审批人姓名(可选)"
  127. />
  128. </view>
  129. </view>
  130. </view>
  131. </view>
  132. <view class="bx-sheet__footer" style="display: flex; gap: 10px">
  133. <button class="btn btn-secondary" style="flex: 1" @click="showForm = false">
  134. 取消
  135. </button>
  136. <button
  137. class="btn btn-primary"
  138. style="flex: 1"
  139. :disabled="!!loadingMap.submit"
  140. @click="handleSubmit"
  141. >
  142. {{ loadingMap.submit ? '保存中...' : '保存' }}
  143. </button>
  144. </view>
  145. </view>
  146. </view>
  147. <!-- 审批人选择 -->
  148. <view v-if="showApproverPicker" class="bx-sheet-mask" @click="closeApproverPicker">
  149. <view class="bx-sheet bx-sheet--tall" @click.stop>
  150. <view class="bx-sheet__title">选择审批人</view>
  151. <view class="search-wrap" style="padding: 8px 16px 0">
  152. <view class="search-box">
  153. <input
  154. v-model="approverKeyword"
  155. class="search-input"
  156. placeholder="搜索姓名或账号"
  157. confirm-type="search"
  158. />
  159. <view
  160. v-if="approverKeyword.trim()"
  161. class="search-box__clear"
  162. @click="approverKeyword = ''"
  163. >×</view
  164. >
  165. </view>
  166. </view>
  167. <scroll-view scroll-y class="bx-sheet__body">
  168. <view v-if="!filteredUsers.length" class="bx-sheet__empty">无匹配人员</view>
  169. <view
  170. v-for="u in filteredUsers"
  171. :key="u.id"
  172. class="bx-sheet__item"
  173. :class="{ 'is-selected': String(formData.approverId) === u.id }"
  174. @click="onApproverPick(u)"
  175. >
  176. {{ u.displayName }}
  177. </view>
  178. </scroll-view>
  179. </view>
  180. </view>
  181. </view>
  182. </template>
  183. <script setup>
  184. import { ref, reactive, computed, onMounted } from 'vue'
  185. import { workflowApi, fetchPortalUserOptions } from '@/api/bx'
  186. import { statusLabel, adminStatusTagClass } from '@/utils/bxFormat'
  187. import { PAGE_PATHS } from '@/config/api'
  188. import { getSafeAreaStyle } from '@/utils/safeArea'
  189. const safeAreaStyle = getSafeAreaStyle()
  190. const props = defineProps({
  191. bizType: { type: String, required: true },
  192. pageTitle: { type: String, required: true }
  193. })
  194. const wfDef = ref({})
  195. const nodes = computed(() => wfDef.value.nodes || [])
  196. const loading = ref(false)
  197. const showForm = ref(false)
  198. const showApproverPicker = ref(false)
  199. const isEdit = ref(false)
  200. const loadingMap = reactive({})
  201. const approverKeyword = ref('')
  202. const approverFallback = ref(false)
  203. const userOptions = ref([])
  204. const filteredUsers = computed(() => {
  205. const kw = approverKeyword.value.trim().toLowerCase()
  206. if (!kw) return userOptions.value
  207. return userOptions.value.filter((u) => {
  208. const name = (u.realName || '').toLowerCase()
  209. const account = (u.account || '').toLowerCase()
  210. const display = (u.displayName || '').toLowerCase()
  211. return name.includes(kw) || account.includes(kw) || display.includes(kw)
  212. })
  213. })
  214. const formData = reactive({
  215. editingNodeId: null,
  216. nodeName: '',
  217. approverId: null,
  218. approverName: ''
  219. })
  220. const approverDisplay = computed(() => {
  221. if (formData.approverId == null || formData.approverId === '') return ''
  222. return (
  223. userOptions.value.find((u) => u.id === String(formData.approverId))?.displayName ??
  224. formData.approverName
  225. )
  226. })
  227. function nodeKey(node) {
  228. return String(node.id ?? node.nodeOrder ?? node.nodeName ?? Date.now())
  229. }
  230. async function loadUsers() {
  231. try {
  232. const data = await fetchPortalUserOptions()
  233. userOptions.value = (Array.isArray(data) ? data : []).map((u) => {
  234. const name = u.label || u.nickName || u.realName || ''
  235. const account = u.userNo || u.username || ''
  236. return {
  237. id: String(u.value ?? u.id ?? ''),
  238. realName: name,
  239. account,
  240. displayName: account ? `${name}(${account})` : name
  241. }
  242. }).filter((u) => u.id)
  243. approverFallback.value = userOptions.value.length === 0
  244. } catch (e) {
  245. userOptions.value = []
  246. approverFallback.value = true
  247. }
  248. }
  249. async function loadWfDef() {
  250. loading.value = true
  251. try {
  252. const res = await workflowApi.getByBizType(props.bizType)
  253. wfDef.value = res.data || res || {}
  254. } catch (e) {
  255. wfDef.value = {}
  256. } finally {
  257. loading.value = false
  258. }
  259. }
  260. function buildNodePayload(list) {
  261. return list.map((n, i) => ({
  262. nodeName: n.nodeName,
  263. nodeOrder: i + 1,
  264. approverId: n.approverId ?? n.approver_id ?? null,
  265. approverName: n.approverName ?? n.approver_name ?? ''
  266. }))
  267. }
  268. function openAddDialog() {
  269. if (!wfDef.value?.id) {
  270. uni.showToast({ title: '工作流未初始化,无法保存节点', icon: 'none' })
  271. return
  272. }
  273. isEdit.value = false
  274. Object.assign(formData, {
  275. editingNodeId: null,
  276. nodeName: '',
  277. approverId: null,
  278. approverName: ''
  279. })
  280. showForm.value = true
  281. }
  282. function openEditDialog(node) {
  283. isEdit.value = true
  284. const aid = node.approverId ?? node.approver_id ?? null
  285. Object.assign(formData, {
  286. editingNodeId: node.id != null ? String(node.id) : null,
  287. nodeName: node.nodeName ?? '',
  288. approverId: aid != null && aid !== '' ? String(aid) : null,
  289. approverName: node.approverName ?? node.approver_name ?? ''
  290. })
  291. showForm.value = true
  292. }
  293. function openApproverPicker() {
  294. if (!userOptions.value.length) {
  295. if (approverFallback.value) {
  296. uni.showToast({ title: '请手动填写审批人', icon: 'none' })
  297. return
  298. }
  299. uni.showToast({ title: '暂无人员', icon: 'none' })
  300. return
  301. }
  302. approverKeyword.value = ''
  303. showApproverPicker.value = true
  304. }
  305. function closeApproverPicker() {
  306. showApproverPicker.value = false
  307. approverKeyword.value = ''
  308. }
  309. function onApproverPick(u) {
  310. formData.approverId = u.id
  311. formData.approverName = (u.displayName || '').split('(')[0]
  312. closeApproverPicker()
  313. }
  314. function validateForm() {
  315. if (!String(formData.nodeName ?? '').trim()) {
  316. uni.showToast({ title: '请输入节点名称', icon: 'none' })
  317. return false
  318. }
  319. return true
  320. }
  321. async function handleSubmit() {
  322. if (!validateForm()) return
  323. if (!wfDef.value?.id) {
  324. uni.showToast({ title: '工作流未初始化', icon: 'none' })
  325. return
  326. }
  327. loadingMap.submit = true
  328. try {
  329. if (isEdit.value && formData.editingNodeId != null) {
  330. const newNodes = nodes.value.map((n) =>
  331. String(n.id) === formData.editingNodeId
  332. ? {
  333. ...n,
  334. nodeName: formData.nodeName,
  335. approverId: formData.approverId,
  336. approverName: formData.approverName
  337. }
  338. : n
  339. )
  340. await workflowApi.saveNodes(wfDef.value.id, buildNodePayload(newNodes))
  341. uni.showToast({ title: '编辑成功', icon: 'success' })
  342. } else {
  343. const newNodes = [
  344. ...nodes.value,
  345. {
  346. nodeName: formData.nodeName,
  347. approverId: formData.approverId,
  348. approverName: formData.approverName
  349. }
  350. ]
  351. await workflowApi.saveNodes(wfDef.value.id, buildNodePayload(newNodes))
  352. uni.showToast({ title: '新增成功', icon: 'success' })
  353. }
  354. showForm.value = false
  355. await loadWfDef()
  356. } catch (e) {
  357. // toasted
  358. } finally {
  359. loadingMap.submit = false
  360. }
  361. }
  362. function handleDelete(node) {
  363. const key = `delete-${nodeKey(node)}`
  364. uni.showModal({
  365. title: '删除确认',
  366. content: `确定要删除节点「${node.nodeName}」吗?`,
  367. confirmText: '确定删除',
  368. confirmColor: '#ef4444',
  369. success: async (r) => {
  370. if (!r.confirm) return
  371. loadingMap[key] = true
  372. try {
  373. const newNodes = nodes.value.filter((n) => n.id !== node.id)
  374. await workflowApi.saveNodes(wfDef.value.id, buildNodePayload(newNodes))
  375. uni.showToast({ title: '删除成功', icon: 'success' })
  376. await loadWfDef()
  377. } catch (e) {
  378. // toasted
  379. } finally {
  380. loadingMap[key] = false
  381. }
  382. }
  383. })
  384. }
  385. async function moveUp(node) {
  386. const idx = nodes.value.findIndex((n) => n.id === node.id)
  387. if (idx <= 0) return
  388. const arr = [...nodes.value]
  389. const temp = arr[idx]
  390. arr[idx] = arr[idx - 1]
  391. arr[idx - 1] = temp
  392. const key = `up-${nodeKey(node)}`
  393. try {
  394. loadingMap[key] = true
  395. await workflowApi.saveNodes(wfDef.value.id, buildNodePayload(arr))
  396. uni.showToast({ title: '上移成功', icon: 'success' })
  397. await loadWfDef()
  398. } catch (e) {
  399. // toasted
  400. } finally {
  401. loadingMap[key] = false
  402. }
  403. }
  404. async function moveDown(node) {
  405. const arr = [...nodes.value]
  406. const idx = arr.findIndex((n) => n.id === node.id)
  407. if (idx < 0 || idx >= arr.length - 1) return
  408. const temp = arr[idx]
  409. arr[idx] = arr[idx + 1]
  410. arr[idx + 1] = temp
  411. const key = `down-${nodeKey(node)}`
  412. try {
  413. loadingMap[key] = true
  414. await workflowApi.saveNodes(wfDef.value.id, buildNodePayload(arr))
  415. uni.showToast({ title: '下移成功', icon: 'success' })
  416. await loadWfDef()
  417. } catch (e) {
  418. // toasted
  419. } finally {
  420. loadingMap[key] = false
  421. }
  422. }
  423. function onBack() {
  424. uni.navigateBack({ fail: () => uni.reLaunch({ url: PAGE_PATHS.workbench }) })
  425. }
  426. onMounted(() => {
  427. loadWfDef()
  428. loadUsers()
  429. })
  430. </script>
  431. <style lang="scss">
  432. @import '@/styles/bx-theme.scss';
  433. </style>