bx-wf-config-page.vue 14 KB

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