editor.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649
  1. <template>
  2. <view class="container" :style="{ '--font-scale': fontScale }">
  3. <!-- 加载状态 -->
  4. <view v-if="loading" class="loading">
  5. <text>加载中...</text>
  6. </view>
  7. <!-- 表单内容 -->
  8. <view v-else class="form-container">
  9. <!-- 基本信息 -->
  10. <view class="section">
  11. <view class="section-title">基本信息</view>
  12. <view class="form-item">
  13. <text class="label">流程名称 <text class="required">*</text></text>
  14. <input v-model="flowName" class="input" placeholder="请输入流程名称" />
  15. </view>
  16. <view class="form-item">
  17. <text class="label">流程描述</text>
  18. <textarea v-model="flowDescription" class="textarea" placeholder="可选,描述流程用途" />
  19. </view>
  20. </view>
  21. <!-- 节点列表 -->
  22. <view class="section">
  23. <view class="section-title">
  24. <text>任务节点</text>
  25. <text class="node-count">{{ nodes.length }} 个节点</text>
  26. </view>
  27. <!-- 节点列表 -->
  28. <view v-for="(node, index) in nodes" :key="getNodeKey(index)" class="node-item">
  29. <view class="node-header">
  30. <view class="node-order">
  31. <text class="order-num">{{ index + 1 }}</text>
  32. </view>
  33. <view class="node-info">
  34. <text class="node-template">{{ getNodeTemplateName(node) }}</text>
  35. <text class="node-id">{{ node.id }}</text>
  36. </view>
  37. <view class="node-actions">
  38. <button class="btn-icon" @click="handleEditNode(node)">✏️</button>
  39. <button class="btn-icon btn-delete" @click="handleDeleteNode(index)">🗑️</button>
  40. <button v-if="index > 0" class="btn-icon" @click="handleMoveNode(index, -1)">↑</button>
  41. <button v-if="index < nodes.length - 1" class="btn-icon" @click="handleMoveNode(index, 1)">↓</button>
  42. </view>
  43. </view>
  44. <view class="node-preview">
  45. <text class="node-title">{{ node.title }}</text>
  46. </view>
  47. </view>
  48. <!-- 添加节点按钮 -->
  49. <view class="add-node-btn" @click="showNodePicker">
  50. <text class="add-icon">+</text>
  51. <text>添加任务节点</text>
  52. </view>
  53. </view>
  54. <!-- 触发条件设置 -->
  55. <view class="section" v-if="nodes.length > 1">
  56. <view class="section-title">触发条件</view>
  57. <view class="condition-hint">
  58. <text>每个节点将在前一个节点完成后自动触发</text>
  59. </view>
  60. </view>
  61. <!-- 保存按钮 -->
  62. <view class="action-bar">
  63. <button class="btn-save" @click="handleSaveDraft">保存草稿</button>
  64. <button v-if="!isDraft" class="btn-publish" @click="handlePublish">发布</button>
  65. </view>
  66. </view>
  67. <!-- 节点选择弹窗 -->
  68. <view v-if="showNodePickerFlag" class="modal-mask" @click="showNodePickerFlag = false">
  69. <view class="modal modal-large" @click.stop>
  70. <view class="modal-header">
  71. <text class="modal-title">选择任务模板</text>
  72. <button class="btn-close" @click="showNodePickerFlag = false">×</button>
  73. </view>
  74. <view class="search-box">
  75. <input v-model="templateSearch" class="search-input" placeholder="搜索模板..." />
  76. </view>
  77. <scroll-view scroll-y class="template-list">
  78. <view
  79. v-for="tpl in filteredTemplates"
  80. :key="tpl.id"
  81. class="template-item"
  82. @click="addNodeFromTemplate(tpl)"
  83. >
  84. <text class="tpl-name">{{ tpl.title }}</text>
  85. <text class="tpl-points">+{{ tpl.points || 2 }}分</text>
  86. </view>
  87. <view v-if="filteredTemplates.length === 0" class="empty">
  88. <text>无匹配模板</text>
  89. </view>
  90. </scroll-view>
  91. </view>
  92. </view>
  93. <!-- 节点编辑弹窗 -->
  94. <view v-if="showNodeEditFlag" class="modal-mask" @click="closeNodeEdit">
  95. <view class="modal modal-large" @click.stop>
  96. <view class="modal-header">
  97. <text class="modal-title">编辑节点</text>
  98. <button class="btn-close" @click="closeNodeEdit">×</button>
  99. </view>
  100. <view class="form-container">
  101. <view class="form-item">
  102. <text class="label">任务标题</text>
  103. <input v-model="editNode.title" class="input" placeholder="输入任务标题" />
  104. </view>
  105. <view class="form-item">
  106. <text class="label">超时时间(分钟)</text>
  107. <input v-model="editNode.timeout_minutes" class="input" type="number" placeholder="0表示不限制" />
  108. </view>
  109. <view class="form-item">
  110. <text class="label">最大循环次数</text>
  111. <input v-model="editNode.max_loops" class="input" type="number" placeholder="0表示不限制" />
  112. </view>
  113. <view class="form-item">
  114. <text class="label">执行人</text>
  115. <picker :range="executorTypes" @change="onExecutorChange">
  116. <view class="picker">{{ executorLabels[editNode.executorType] || '请选择' }}</view>
  117. </picker>
  118. </view>
  119. </view>
  120. <view class="action-bar">
  121. <button class="btn-save" @click="saveNodeEdit">保存</button>
  122. </view>
  123. </view>
  124. </view>
  125. </view>
  126. </template>
  127. <script>
  128. import { getOrchestrationFlowDetail, saveOrchestrationFlow, publishOrchestrationFlow, getNodeTemplateList } from '../../utils/api.js'
  129. let _nodeSeq = 0
  130. import fontScaleMixin from '@/mixins/font-scale.js'
  131. export default {
  132. mixins: [fontScaleMixin],
  133. data() {
  134. return {
  135. flowId: null,
  136. flowName: '',
  137. flowDescription: '',
  138. nodes: [],
  139. templates: [],
  140. templateSearch: '',
  141. showNodePickerFlag: false,
  142. showNodeEditFlag: false,
  143. editNode: null,
  144. loading: false,
  145. executorTypes: ['child', 'member'],
  146. executorLabels: { child: '儿童', member: '家庭成员' }
  147. }
  148. },
  149. computed: {
  150. isDraft() {
  151. return this.flowId === null
  152. },
  153. filteredTemplates() {
  154. if (!this.templateSearch) return this.templates
  155. var kw = this.templateSearch.toLowerCase()
  156. return this.templates.filter(function(t) {
  157. return t.title.indexOf(kw) >= 0
  158. })
  159. }
  160. },
  161. onLoad(options) {
  162. var id = options.id ? Number(options.id) : null
  163. if (id) {
  164. this.flowId = id
  165. this.loadFlowDetail(id)
  166. } else {
  167. this.flowId = null
  168. this.initEmptyForm()
  169. }
  170. this.loadTemplates()
  171. },
  172. methods: {
  173. initEmptyForm() {
  174. this.flowName = ''
  175. this.flowDescription = ''
  176. this.nodes = []
  177. _nodeSeq = 0
  178. },
  179. async loadFlowDetail(id) {
  180. this.loading = true
  181. try {
  182. var res = await getOrchestrationFlowDetail(id)
  183. var flow = res.data.flow
  184. if (flow) {
  185. this.flowName = flow.name || ''
  186. this.flowDescription = flow.description || ''
  187. // 解析 configJson
  188. if (flow.configJson) {
  189. var cfg = JSON.parse(flow.configJson)
  190. this.nodes = (cfg.nodes || []).map(function(n) {
  191. return {
  192. id: n.id,
  193. task_template_ref: n.task_template_ref,
  194. title: n.title || '',
  195. executorType: n.executorType || 'child',
  196. timeout_minutes: n.timeout_minutes || 0,
  197. max_loops: n.max_loops || 0
  198. }
  199. })
  200. _nodeSeq = this.nodes.length
  201. }
  202. }
  203. } catch (e) {
  204. console.error('加载流程详情失败', e)
  205. uni.showToast({ title: '加载失败', icon: 'none' })
  206. } finally {
  207. this.loading = false
  208. }
  209. },
  210. async loadTemplates() {
  211. try {
  212. var res = await getNodeTemplateList({ page: 1, size: 100 })
  213. this.templates = res.data.records || res.data || []
  214. } catch (e) {
  215. console.error('加载模板失败', e)
  216. this.templates = []
  217. }
  218. },
  219. getNodeKey: function(index) {
  220. return 'node-' + index
  221. },
  222. getNodeTemplateName(node) {
  223. if (!node.task_template_ref) return '未选择模板'
  224. var tpl = this.templates.find(function(t) { return t.id === Number(node.task_template_ref) })
  225. return tpl ? tpl.title : ('模板#' + node.task_template_ref)
  226. },
  227. showNodePicker() {
  228. this.showNodePickerFlag = true
  229. this.templateSearch = ''
  230. },
  231. addNodeFromTemplate(tpl) {
  232. _nodeSeq += 1
  233. var node = {
  234. id: 'n' + _nodeSeq,
  235. task_template_ref: tpl.id,
  236. title: tpl.title,
  237. executorType: 'child',
  238. timeout_minutes: 0,
  239. max_loops: 0
  240. }
  241. this.nodes.push(node)
  242. this.showNodePickerFlag = false
  243. uni.showToast({ title: '已添加节点', icon: 'success' })
  244. },
  245. handleEditNode(node) {
  246. this.editNode = JSON.parse(JSON.stringify(node))
  247. this.showNodeEditFlag = true
  248. },
  249. closeNodeEdit() {
  250. this.showNodeEditFlag = false
  251. this.editNode = null
  252. },
  253. saveNodeEdit() {
  254. if (!this.editNode) return
  255. var idx = this.nodes.findIndex(function(n) { return n.id === this.editNode.id })
  256. if (idx >= 0) {
  257. this.$set(this.nodes, idx, JSON.parse(JSON.stringify(this.editNode)))
  258. }
  259. this.closeNodeEdit()
  260. },
  261. onExecutorChange(e) {
  262. var idx = e.detail.value
  263. if (this.editNode) {
  264. this.editNode.executorType = this.executorTypes[idx]
  265. }
  266. },
  267. handleDeleteNode(index) {
  268. uni.showModal({
  269. title: '确认删除',
  270. content: '确定要删除这个节点吗?',
  271. success: (res) => {
  272. if (res.confirm) {
  273. this.nodes.splice(index, 1)
  274. }
  275. }
  276. })
  277. },
  278. handleMoveNode(index, direction) {
  279. var newIndex = index + direction
  280. if (newIndex < 0 || newIndex >= this.nodes.length) return
  281. var temp = this.nodes[index]
  282. this.$set(this.nodes, index, this.nodes[newIndex])
  283. this.$set(this.nodes, newIndex, temp)
  284. },
  285. validateBeforeSave() {
  286. var errs = []
  287. if (!this.flowName.trim()) errs.push('请填写流程名称')
  288. if (this.nodes.length === 0) errs.push('请至少添加一个任务节点')
  289. if (this.nodes.some(function(n) { return !n.task_template_ref })) {
  290. errs.push('存在未绑定任务模板的节点')
  291. }
  292. return errs
  293. },
  294. async handleSaveDraft() {
  295. var errs = this.validateBeforeSave()
  296. if (errs.length > 0) {
  297. uni.showToast({ title: errs[0], icon: 'none' })
  298. return
  299. }
  300. uni.showLoading({ title: '保存中...' })
  301. try {
  302. var cfg = {
  303. nodes: this.nodes.map(function(n) {
  304. return {
  305. id: n.id,
  306. task_template_ref: String(n.task_template_ref),
  307. title: n.title,
  308. executorType: n.executorType,
  309. timeout_minutes: n.timeout_minutes,
  310. max_loops: n.max_loops
  311. }
  312. })
  313. }
  314. var res = await saveOrchestrationFlow({
  315. id: this.flowId,
  316. name: this.flowName,
  317. description: this.flowDescription,
  318. configJson: JSON.stringify(cfg)
  319. })
  320. this.flowId = res.data.id || this.flowId
  321. uni.hideLoading()
  322. uni.showToast({ title: '保存成功', icon: 'success' })
  323. setTimeout(function() {
  324. uni.navigateBack()
  325. }, 1000)
  326. } catch (e) {
  327. uni.hideLoading()
  328. uni.showToast({ title: e.message || '保存失败', icon: 'none' })
  329. }
  330. },
  331. async handlePublish() {
  332. var errs = this.validateBeforeSave()
  333. if (errs.length > 0) {
  334. uni.showToast({ title: errs[0], icon: 'none' })
  335. return
  336. }
  337. // 先保存
  338. uni.showLoading({ title: '发布中...' })
  339. try {
  340. var cfg = {
  341. nodes: this.nodes.map(function(n) {
  342. return {
  343. id: n.id,
  344. task_template_ref: String(n.task_template_ref),
  345. title: n.title,
  346. executorType: n.executorType,
  347. timeout_minutes: n.timeout_minutes,
  348. max_loops: n.max_loops
  349. }
  350. })
  351. }
  352. var saveRes = await saveOrchestrationFlow({
  353. id: this.flowId,
  354. name: this.flowName,
  355. description: this.flowDescription,
  356. configJson: JSON.stringify(cfg)
  357. })
  358. this.flowId = saveRes.data.id || this.flowId
  359. // 再发布
  360. await publishOrchestrationFlow(this.flowId)
  361. uni.hideLoading()
  362. uni.showToast({ title: '发布成功', icon: 'success' })
  363. setTimeout(function() {
  364. uni.navigateBack()
  365. }, 1000)
  366. } catch (e) {
  367. uni.hideLoading()
  368. uni.showToast({ title: e.message || '发布失败', icon: 'none' })
  369. }
  370. }
  371. }
  372. }
  373. </script>
  374. <style scoped lang="scss">
  375. .container {
  376. min-height: 100vh;
  377. background: $facade-fill-page;
  378. }
  379. .loading {
  380. text-align: center;
  381. padding: 100rpx;
  382. color: $app-text-subtle;
  383. }
  384. .form-container {
  385. padding: $uni-space-page;
  386. }
  387. .section {
  388. background: $app-surface;
  389. border-radius: 16rpx;
  390. padding: $uni-space-page;
  391. margin-bottom: 24rpx;
  392. }
  393. .section-title {
  394. font-size: calc(28rpx * var(--font-scale, 1));
  395. font-weight: bold;
  396. color: $uni-text-color;
  397. margin-bottom: 24rpx;
  398. display: flex;
  399. align-items: center;
  400. justify-content: space-between;
  401. }
  402. .node-count {
  403. font-size: calc(24rpx * var(--font-scale, 1));
  404. color: $app-text-subtle;
  405. font-weight: normal;
  406. }
  407. .form-item {
  408. margin-bottom: 24rpx;
  409. }
  410. .label {
  411. display: block;
  412. font-size: calc(26rpx * var(--font-scale, 1));
  413. color: $uni-text-color-secondary;
  414. margin-bottom: 12rpx;
  415. }
  416. .required {
  417. color: $status-danger;
  418. }
  419. .input {
  420. width: 100%;
  421. height: 80rpx;
  422. border: 2rpx solid $uni-border-color-light;
  423. border-radius: 16rpx;
  424. padding: 0 20rpx;
  425. font-size: calc(28rpx * var(--font-scale, 1));
  426. box-sizing: border-box;
  427. }
  428. .textarea {
  429. width: 100%;
  430. height: 160rpx;
  431. border: 2rpx solid $uni-border-color-light;
  432. border-radius: 16rpx;
  433. padding: 20rpx;
  434. font-size: calc(28rpx * var(--font-scale, 1));
  435. box-sizing: border-box;
  436. }
  437. .picker {
  438. width: 100%;
  439. height: 80rpx;
  440. border: 2rpx solid $uni-border-color-light;
  441. border-radius: 16rpx;
  442. padding: 0 20rpx;
  443. font-size: calc(28rpx * var(--font-scale, 1));
  444. line-height: 80rpx;
  445. }
  446. .node-item {
  447. background: $facade-fill-slate-50;
  448. border: 2rpx solid $uni-border-color-light;
  449. border-radius: 16rpx;
  450. padding: 20rpx;
  451. margin-bottom: 16rpx;
  452. }
  453. .node-header {
  454. display: flex;
  455. align-items: center;
  456. }
  457. .node-order {
  458. width: 48rpx;
  459. height: 48rpx;
  460. background: $brand-primary;
  461. color: $app-surface;
  462. border-radius: 50%;
  463. display: flex;
  464. align-items: center;
  465. justify-content: center;
  466. margin-right: 16rpx;
  467. }
  468. .order-num {
  469. font-size: calc(24rpx * var(--font-scale, 1));
  470. font-weight: bold;
  471. }
  472. .node-info {
  473. flex: 1;
  474. min-width: 0;
  475. }
  476. .node-template {
  477. display: block;
  478. font-size: calc(26rpx * var(--font-scale, 1));
  479. color: $uni-text-color;
  480. font-weight: 500;
  481. overflow: hidden;
  482. text-overflow: ellipsis;
  483. white-space: nowrap;
  484. }
  485. .node-id {
  486. display: block;
  487. font-size: calc(20rpx * var(--font-scale, 1));
  488. color: $app-text-subtle;
  489. }
  490. .node-actions {
  491. display: flex;
  492. gap: 8rpx;
  493. }
  494. .btn-icon {
  495. width: 48rpx;
  496. height: 48rpx;
  497. border-radius: 8rpx;
  498. background: $app-surface;
  499. border: 1rpx solid $uni-border-color-light;
  500. font-size: calc(24rpx * var(--font-scale, 1));
  501. display: flex;
  502. align-items: center;
  503. justify-content: center;
  504. padding: 0;
  505. }
  506. .btn-icon::after { border: none; }
  507. .btn-delete { color: $status-danger; }
  508. .node-preview {
  509. margin-top: 12rpx;
  510. padding-left: 64rpx;
  511. }
  512. .node-title {
  513. font-size: calc(24rpx * var(--font-scale, 1));
  514. color: $uni-text-color-secondary;
  515. }
  516. .add-node-btn {
  517. border: 2rpx dashed $brand-primary;
  518. border-radius: 16rpx;
  519. padding: 28rpx 0;
  520. text-align: center;
  521. color: $brand-primary;
  522. }
  523. .add-icon {
  524. font-size: calc(36rpx * var(--font-scale, 1));
  525. margin-right: 8rpx;
  526. }
  527. .condition-hint {
  528. font-size: calc(24rpx * var(--font-scale, 1));
  529. color: $app-text-subtle;
  530. padding: 16rpx;
  531. background: $facade-fill-slate-50;
  532. border-radius: 8rpx;
  533. }
  534. .action-bar {
  535. padding: $uni-space-page;
  536. display: flex;
  537. gap: 20rpx;
  538. }
  539. .btn-save, .btn-publish {
  540. flex: 1;
  541. height: 88rpx;
  542. line-height: 88rpx;
  543. border-radius: 24rpx;
  544. font-size: calc(30rpx * var(--font-scale, 1));
  545. font-weight: bold;
  546. border: none;
  547. }
  548. .btn-save::after, .btn-publish::after { border: none; }
  549. .btn-save { background: $facade-fill-slate; color: $uni-text-color-secondary; }
  550. .btn-publish { background: linear-gradient(135deg, $brand-primary 0%, $brand-primary-dark 100%); color: $app-surface; }
  551. .modal-mask {
  552. position: fixed;
  553. top: 0;
  554. left: 0;
  555. right: 0;
  556. bottom: 0;
  557. background: rgba(0, 0, 0, 0.5);
  558. z-index: 1000;
  559. display: flex;
  560. align-items: flex-end;
  561. }
  562. .modal {
  563. width: 100%;
  564. background: $app-surface;
  565. border-radius: 24rpx 24rpx 0 0;
  566. max-height: 90vh;
  567. overflow: hidden;
  568. display: flex;
  569. flex-direction: column;
  570. }
  571. .modal-large {
  572. max-height: 80vh;
  573. }
  574. .modal-header {
  575. padding: $uni-space-page;
  576. border-bottom: 1rpx solid $uni-border-color-light;
  577. display: flex;
  578. align-items: center;
  579. justify-content: space-between;
  580. }
  581. .modal-title {
  582. font-size: calc(32rpx * var(--font-scale, 1));
  583. font-weight: bold;
  584. color: $uni-text-color;
  585. }
  586. .btn-close {
  587. width: 56rpx;
  588. height: 56rpx;
  589. border-radius: 50%;
  590. background: $facade-fill-slate;
  591. font-size: calc(32rpx * var(--font-scale, 1));
  592. line-height: 56rpx;
  593. text-align: center;
  594. padding: 0;
  595. border: none;
  596. }
  597. .btn-close::after { border: none; }
  598. .search-box {
  599. padding: 20rpx $uni-space-page;
  600. border-bottom: 1rpx solid $uni-border-color-light;
  601. }
  602. .search-input {
  603. width: 100%;
  604. height: 72rpx;
  605. border: 2rpx solid $uni-border-color-light;
  606. border-radius: 24rpx;
  607. padding: 0 24rpx;
  608. font-size: calc(26rpx * var(--font-scale, 1));
  609. box-sizing: border-box;
  610. }
  611. .template-list {
  612. flex: 1;
  613. overflow-y: auto;
  614. padding: 20rpx $uni-space-page;
  615. }
  616. .template-item {
  617. padding: 24rpx 0;
  618. border-bottom: 1rpx solid $facade-fill-slate;
  619. display: flex;
  620. justify-content: space-between;
  621. align-items: center;
  622. }
  623. .tpl-name {
  624. font-size: calc(28rpx * var(--font-scale, 1));
  625. color: $uni-text-color;
  626. }
  627. .tpl-points {
  628. font-size: calc(24rpx * var(--font-scale, 1));
  629. color: $brand-primary;
  630. font-weight: 500;
  631. }
  632. .empty {
  633. text-align: center;
  634. padding: 60rpx;
  635. color: $app-text-subtle;
  636. }
  637. </style>