index.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743
  1. <template>
  2. <view class="page">
  3. <view class="toolbar">
  4. <view class="toolbar-row">
  5. <view class="search-box" :class="{ 'is-focus': appNameFocused }">
  6. <view class="search-ico" aria-hidden="true" />
  7. <input
  8. class="search-input"
  9. :value="appName"
  10. confirm-type="search"
  11. placeholder="搜索流程名称"
  12. placeholder-class="search-ph"
  13. @input="onAppNameInput"
  14. @confirm="onSearchConfirm"
  15. @focus="appNameFocused = true"
  16. @blur="appNameFocused = false"
  17. />
  18. <view v-if="appName" class="search-clear" @click="clearAppName">
  19. <text class="search-clear__x">×</text>
  20. </view>
  21. </view>
  22. <view
  23. class="manage-btn"
  24. :class="{ 'is-active': manageMode }"
  25. @click="toggleManage"
  26. >
  27. {{ manageMode ? '完成' : '管理' }}
  28. </view>
  29. </view>
  30. <view class="toolbar-row toolbar-row--sub">
  31. <view class="search-box" :class="{ 'is-focus': initiatorFocused }">
  32. <input
  33. class="search-input"
  34. :value="initiatorName"
  35. confirm-type="search"
  36. placeholder="搜索申请人"
  37. placeholder-class="search-ph"
  38. @input="onInitiatorInput"
  39. @confirm="onSearchConfirm"
  40. @focus="initiatorFocused = true"
  41. @blur="initiatorFocused = false"
  42. />
  43. <view v-if="initiatorName" class="search-clear" @click="clearInitiator">
  44. <text class="search-clear__x">×</text>
  45. </view>
  46. </view>
  47. </view>
  48. <view class="read-tabs">
  49. <view
  50. v-for="tab in readTabs"
  51. :key="tab.value"
  52. class="read-tab"
  53. :class="{ 'is-active': readStatus === tab.value }"
  54. @click="setReadStatus(tab.value)"
  55. >
  56. {{ tab.label }}
  57. </view>
  58. </view>
  59. </view>
  60. <view v-if="manageMode && selectedIds.length" class="batch-bar">
  61. <text class="batch-bar__hint">已选 {{ selectedIds.length }} 项</text>
  62. <view
  63. class="batch-bar__btn"
  64. :class="{ 'is-disabled': batchReadDisabled }"
  65. @click="batchMarkRead"
  66. >
  67. 批量已读
  68. </view>
  69. </view>
  70. <view v-if="loading && !list.length" class="state state-loading">
  71. <view class="pulse-dots" aria-hidden="true">
  72. <view class="pulse-dot" />
  73. <view class="pulse-dot" />
  74. <view class="pulse-dot" />
  75. </view>
  76. <text class="state-loading__text">加载抄送…</text>
  77. </view>
  78. <view v-else-if="!list.length" class="oa-task-list__empty">暂无抄送</view>
  79. <view v-else class="oa-task-list">
  80. <view
  81. v-for="row in list"
  82. :key="row.id"
  83. class="cc-row"
  84. :class="{ 'is-manage': manageMode, 'is-selected': isSelected(row.id) }"
  85. >
  86. <view
  87. v-if="manageMode"
  88. class="cc-check"
  89. @click.stop="toggleSelect(row.id)"
  90. >
  91. <view class="cc-check__box" :class="{ on: isSelected(row.id) }">
  92. <text v-if="isSelected(row.id)" class="cc-check__mark">✓</text>
  93. </view>
  94. </view>
  95. <view class="cc-card-wrap">
  96. <OaTaskCard
  97. :title="cardTitle(row)"
  98. :status-text="statusText(row.instanceStatus)"
  99. :status-tone="statusTone(row.instanceStatus)"
  100. :unread="!row.isRead"
  101. :meta-lines="metaLines(row)"
  102. :actions="cardActionsFor(row)"
  103. @open="onCardOpen(row)"
  104. @action="onCardAction($event, row)"
  105. />
  106. </view>
  107. </view>
  108. <view v-if="loadingMore" class="oa-task-list__end">加载更多…</view>
  109. <view v-else-if="!hasMore" class="oa-task-list__end">已经到底了</view>
  110. </view>
  111. </view>
  112. </template>
  113. <script>
  114. import OaTaskCard from '../../../components/OaTaskCard.vue'
  115. import {
  116. fetchBatchMarkOaCcTasksRead,
  117. fetchMarkOaCcTaskRead,
  118. fetchOaTasks
  119. } from '../../../utils/oa-api.js'
  120. import { forceRelogin, getToken } from '../../../utils/auth.js'
  121. import { stashOaDetailTitle } from '../../../utils/oa-h5.js'
  122. const PAGE_SIZE = 20
  123. const STATUS_MAP = {
  124. pending: '待处理',
  125. running: '进行中',
  126. approved: '已同意',
  127. rejected: '已拒绝',
  128. completed: '已完成',
  129. cancelled: '已撤销',
  130. done: '已结束',
  131. returned: '已打回',
  132. transferred: '已转交'
  133. }
  134. export default {
  135. components: { OaTaskCard },
  136. data() {
  137. return {
  138. appName: '',
  139. initiatorName: '',
  140. readStatus: 'unread',
  141. appNameFocused: false,
  142. initiatorFocused: false,
  143. list: [],
  144. page: 1,
  145. total: 0,
  146. loading: false,
  147. loadingMore: false,
  148. hasMore: true,
  149. manageMode: false,
  150. selectedIds: [],
  151. openingId: '',
  152. markingId: '',
  153. batchMarking: false,
  154. _searchTimer: null,
  155. readTabs: [
  156. { value: 'unread', label: '未读' },
  157. { value: 'read', label: '已读' },
  158. { value: 'all', label: '全部' }
  159. ]
  160. }
  161. },
  162. computed: {
  163. batchReadDisabled() {
  164. const ids = this.selectedIds || []
  165. if (!ids.length) return true
  166. return !this.list.some(
  167. (r) => r && ids.indexOf(r.id) >= 0 && !r.isRead
  168. )
  169. }
  170. },
  171. onShow() {
  172. if (!getToken()) {
  173. forceRelogin('请先登录')
  174. return
  175. }
  176. this.loadList(true)
  177. },
  178. onPullDownRefresh() {
  179. this.loadList(true)
  180. .catch(() => {})
  181. .finally(() => {
  182. uni.stopPullDownRefresh()
  183. })
  184. },
  185. onReachBottom() {
  186. if (!this.hasMore || this.loading || this.loadingMore) return
  187. this.loadList(false)
  188. },
  189. onUnload() {
  190. if (this._searchTimer) {
  191. clearTimeout(this._searchTimer)
  192. this._searchTimer = null
  193. }
  194. },
  195. methods: {
  196. cardTitle(row) {
  197. return (row && (row.appName || row.title)) || '-'
  198. },
  199. statusText(s) {
  200. if (!s) return '-'
  201. return STATUS_MAP[s] || s
  202. },
  203. statusTone(s) {
  204. if (s === 'pending' || s === 'running') return 'warn'
  205. if (s === 'approved' || s === 'completed' || s === 'done') return 'success'
  206. if (s === 'rejected' || s === 'cancelled' || s === 'returned') return 'danger'
  207. return 'info'
  208. },
  209. metaLines(row) {
  210. return [
  211. {
  212. label: '申请人',
  213. value: (row && row.initiatorUserName) || '-'
  214. },
  215. {
  216. label: '申请时间',
  217. value: (row && row.applyTime) || '-'
  218. },
  219. {
  220. label: '阅读',
  221. value: row && row.isRead ? '已读' : '未读'
  222. }
  223. ]
  224. },
  225. cardActionsFor(row) {
  226. if (this.manageMode) return []
  227. return [
  228. { key: 'view', label: '查看', primary: true },
  229. {
  230. key: 'read',
  231. label: '已读',
  232. disabled: !!(row && row.isRead)
  233. },
  234. { key: 'print', label: '打印' }
  235. ]
  236. },
  237. isSelected(id) {
  238. return this.selectedIds.indexOf(id) >= 0
  239. },
  240. scheduleSearch() {
  241. if (this._searchTimer) clearTimeout(this._searchTimer)
  242. this._searchTimer = setTimeout(() => {
  243. this.loadList(true)
  244. }, 400)
  245. },
  246. onAppNameInput(e) {
  247. this.appName = this._inputVal(e)
  248. this.scheduleSearch()
  249. },
  250. onInitiatorInput(e) {
  251. this.initiatorName = this._inputVal(e)
  252. this.scheduleSearch()
  253. },
  254. _inputVal(e) {
  255. if (e && e.detail && e.detail.value != null) return String(e.detail.value)
  256. if (e && e.target) return String(e.target.value || '')
  257. return ''
  258. },
  259. onSearchConfirm() {
  260. if (this._searchTimer) {
  261. clearTimeout(this._searchTimer)
  262. this._searchTimer = null
  263. }
  264. this.loadList(true)
  265. },
  266. clearAppName() {
  267. this.appName = ''
  268. this.onSearchConfirm()
  269. },
  270. clearInitiator() {
  271. this.initiatorName = ''
  272. this.onSearchConfirm()
  273. },
  274. setReadStatus(value) {
  275. if (this.readStatus === value) return
  276. this.readStatus = value
  277. this.loadList(true)
  278. },
  279. toggleManage() {
  280. this.manageMode = !this.manageMode
  281. if (!this.manageMode) this.selectedIds = []
  282. },
  283. toggleSelect(id) {
  284. if (!id) return
  285. const i = this.selectedIds.indexOf(id)
  286. if (i >= 0) {
  287. this.selectedIds = this.selectedIds.filter((x) => x !== id)
  288. } else {
  289. this.selectedIds = this.selectedIds.concat([id])
  290. }
  291. },
  292. onCardOpen(row) {
  293. if (this.manageMode) {
  294. this.toggleSelect(row && row.id)
  295. return
  296. }
  297. this.openView(row)
  298. },
  299. onCardAction(act, row) {
  300. if (!act || !row) return
  301. if (act.key === 'view') this.openView(row)
  302. else if (act.key === 'read') this.markOneRead(row)
  303. else if (act.key === 'print') this.openView(row, { autoPrint: true })
  304. },
  305. async markOneRead(row) {
  306. if (!row || row.isRead || this.markingId) return
  307. const taskId = String(row.id || '').trim()
  308. if (!taskId) return
  309. this.markingId = taskId
  310. try {
  311. await fetchMarkOaCcTaskRead(taskId)
  312. uni.showToast({ title: '已标记已读', icon: 'success' })
  313. await this.loadList(true)
  314. } catch (e) {
  315. uni.showToast({
  316. title: (e && e.message) || '标记已读失败',
  317. icon: 'none'
  318. })
  319. } finally {
  320. this.markingId = ''
  321. }
  322. },
  323. async batchMarkRead() {
  324. if (this.batchReadDisabled || this.batchMarking) return
  325. const selected = this.selectedIds || []
  326. const ids = this.list
  327. .filter((r) => r && selected.indexOf(r.id) >= 0 && !r.isRead)
  328. .map((r) => String(r.id || '').trim())
  329. .filter(Boolean)
  330. if (!ids.length) return
  331. this.batchMarking = true
  332. try {
  333. await fetchBatchMarkOaCcTasksRead(ids)
  334. uni.showToast({ title: '已标记已读', icon: 'success' })
  335. this.selectedIds = []
  336. this.manageMode = false
  337. await this.loadList(true)
  338. } catch (e) {
  339. uni.showToast({
  340. title: (e && e.message) || '批量已读失败',
  341. icon: 'none'
  342. })
  343. } finally {
  344. this.batchMarking = false
  345. }
  346. },
  347. async openView(row, opts) {
  348. if (!row || this.openingId) return
  349. const instanceId = String(row.instanceId || '').trim()
  350. const taskId = String(row.id || '').trim()
  351. if (!instanceId) {
  352. uni.showToast({ title: '缺少实例 ID', icon: 'none' })
  353. return
  354. }
  355. const autoPrint = !!(opts && opts.autoPrint)
  356. this.openingId = taskId || instanceId
  357. try {
  358. if (taskId && !row.isRead) {
  359. try {
  360. await fetchMarkOaCcTaskRead(taskId)
  361. row.isRead = true
  362. } catch (e) {
  363. uni.showToast({
  364. title: (e && e.message) || '标记已读失败',
  365. icon: 'none'
  366. })
  367. }
  368. }
  369. stashOaDetailTitle(row.appName || row.title)
  370. uni.navigateTo({
  371. url:
  372. '/pages/oa/webview/index?instanceId=' +
  373. encodeURIComponent(instanceId) +
  374. '&mode=view' +
  375. (autoPrint ? '&autoPrint=1' : '')
  376. })
  377. } finally {
  378. this.openingId = ''
  379. }
  380. },
  381. async loadList(reset) {
  382. if (!getToken()) {
  383. forceRelogin('请先登录')
  384. return
  385. }
  386. const isReset = !!reset
  387. if (isReset) {
  388. this.page = 1
  389. this.hasMore = true
  390. this.loading = true
  391. this.selectedIds = []
  392. } else {
  393. this.loadingMore = true
  394. }
  395. try {
  396. const res = await fetchOaTasks('cc', {
  397. appName: (this.appName || '').trim() || undefined,
  398. initiatorName: (this.initiatorName || '').trim() || undefined,
  399. readStatus: this.readStatus || 'unread',
  400. page: this.page,
  401. pageSize: PAGE_SIZE
  402. })
  403. const rows = (res && res.list) || []
  404. const total = (res && res.total) != null ? Number(res.total) : 0
  405. this.total = total
  406. if (isReset) this.list = rows
  407. else this.list = this.list.concat(rows)
  408. const loaded = this.list.length
  409. this.hasMore = loaded < total && rows.length > 0
  410. if (rows.length > 0) this.page += 1
  411. } catch (e) {
  412. if (isReset) this.list = []
  413. uni.showToast({
  414. title: (e && e.message) || '加载失败',
  415. icon: 'none'
  416. })
  417. } finally {
  418. this.loading = false
  419. this.loadingMore = false
  420. }
  421. }
  422. }
  423. }
  424. </script>
  425. <style scoped>
  426. .page {
  427. min-height: 100vh;
  428. padding-bottom: 48rpx;
  429. background: #f5f6f8;
  430. box-sizing: border-box;
  431. }
  432. .toolbar {
  433. position: sticky;
  434. top: 0;
  435. z-index: 10;
  436. background: #fff;
  437. padding-bottom: 16rpx;
  438. border-bottom: 1rpx solid #f1f5f9;
  439. }
  440. .toolbar-row {
  441. display: flex;
  442. align-items: center;
  443. gap: 16rpx;
  444. padding: 20rpx 24rpx 0;
  445. box-sizing: border-box;
  446. }
  447. .toolbar-row--sub {
  448. padding-top: 12rpx;
  449. }
  450. .search-box {
  451. flex: 1;
  452. min-width: 0;
  453. display: flex;
  454. align-items: center;
  455. gap: 16rpx;
  456. height: 72rpx;
  457. padding: 0 24rpx;
  458. border-radius: 20rpx;
  459. border: 1rpx solid #e8eaed;
  460. background: #fafafa;
  461. box-sizing: border-box;
  462. transition:
  463. border-color 0.15s,
  464. background 0.15s,
  465. box-shadow 0.15s;
  466. }
  467. .search-box.is-focus {
  468. border-color: #cbd5e1;
  469. background: #fff;
  470. box-shadow: 0 0 0 6rpx rgba(148, 163, 184, 0.18);
  471. }
  472. .search-ico {
  473. position: relative;
  474. width: 26rpx;
  475. height: 26rpx;
  476. border: 3rpx solid #94a3b8;
  477. border-radius: 50%;
  478. box-sizing: border-box;
  479. flex-shrink: 0;
  480. }
  481. .search-ico::after {
  482. content: '';
  483. position: absolute;
  484. width: 12rpx;
  485. height: 3rpx;
  486. right: -8rpx;
  487. bottom: -5rpx;
  488. background: #94a3b8;
  489. border-radius: 3rpx;
  490. transform: rotate(45deg);
  491. }
  492. .search-input {
  493. flex: 1;
  494. height: 72rpx;
  495. font-size: 28rpx;
  496. color: #1f2937;
  497. min-width: 0;
  498. }
  499. .search-ph {
  500. color: #b0b7c3;
  501. }
  502. .search-clear {
  503. display: flex;
  504. align-items: center;
  505. justify-content: center;
  506. width: 40rpx;
  507. height: 40rpx;
  508. border-radius: 50%;
  509. background: #e5e7eb;
  510. flex-shrink: 0;
  511. }
  512. .search-clear__x {
  513. font-size: 28rpx;
  514. line-height: 1;
  515. color: #6b7280;
  516. margin-top: -2rpx;
  517. }
  518. .manage-btn {
  519. flex-shrink: 0;
  520. min-width: 96rpx;
  521. height: 72rpx;
  522. padding: 0 16rpx;
  523. display: flex;
  524. align-items: center;
  525. justify-content: center;
  526. font-size: 28rpx;
  527. font-weight: 600;
  528. color: #166534;
  529. box-sizing: border-box;
  530. }
  531. .manage-btn.is-active {
  532. color: #64748b;
  533. }
  534. .manage-btn:active {
  535. opacity: 0.72;
  536. }
  537. .read-tabs {
  538. display: flex;
  539. align-items: center;
  540. gap: 12rpx;
  541. padding: 16rpx 24rpx 0;
  542. box-sizing: border-box;
  543. }
  544. .read-tab {
  545. flex: 1;
  546. height: 64rpx;
  547. display: flex;
  548. align-items: center;
  549. justify-content: center;
  550. border-radius: 14rpx;
  551. font-size: 26rpx;
  552. font-weight: 600;
  553. color: #64748b;
  554. background: #f8fafc;
  555. border: 1rpx solid #e2e8f0;
  556. box-sizing: border-box;
  557. }
  558. .read-tab.is-active {
  559. color: #166534;
  560. background: #ecfdf5;
  561. border-color: #a7f3d0;
  562. }
  563. .read-tab:active {
  564. opacity: 0.78;
  565. }
  566. .batch-bar {
  567. display: flex;
  568. align-items: center;
  569. justify-content: space-between;
  570. gap: 16rpx;
  571. margin: 16rpx 24rpx 0;
  572. padding: 20rpx 24rpx;
  573. border-radius: 16rpx;
  574. background: #fff;
  575. border: 1rpx solid #d8f3e5;
  576. box-sizing: border-box;
  577. }
  578. .batch-bar__hint {
  579. font-size: 26rpx;
  580. color: #5f7a6d;
  581. }
  582. .batch-bar__btn {
  583. min-height: 64rpx;
  584. padding: 0 28rpx;
  585. display: inline-flex;
  586. align-items: center;
  587. justify-content: center;
  588. border-radius: 14rpx;
  589. font-size: 26rpx;
  590. font-weight: 600;
  591. color: #166534;
  592. background: #ecfdf5;
  593. border: 1rpx solid #a7f3d0;
  594. box-sizing: border-box;
  595. }
  596. .batch-bar__btn.is-disabled {
  597. opacity: 0.45;
  598. pointer-events: none;
  599. }
  600. .batch-bar__btn:active {
  601. opacity: 0.78;
  602. }
  603. .cc-row {
  604. display: flex;
  605. align-items: stretch;
  606. gap: 0;
  607. }
  608. .cc-row.is-manage .cc-card-wrap {
  609. flex: 1;
  610. min-width: 0;
  611. }
  612. .cc-row.is-selected .cc-card-wrap :deep(.oa-task-card) {
  613. border-color: rgba(34, 197, 94, 0.45);
  614. box-shadow: 0 8rpx 32rpx rgba(22, 163, 74, 0.12);
  615. }
  616. .cc-check {
  617. display: flex;
  618. align-items: flex-start;
  619. padding: 40rpx 0 0 4rpx;
  620. margin-right: 12rpx;
  621. flex-shrink: 0;
  622. }
  623. .cc-check__box {
  624. width: 40rpx;
  625. height: 40rpx;
  626. border-radius: 50%;
  627. border: 2rpx solid #cbd5e1;
  628. background: #fff;
  629. display: flex;
  630. align-items: center;
  631. justify-content: center;
  632. box-sizing: border-box;
  633. }
  634. .cc-check__box.on {
  635. border-color: #22c55e;
  636. background: #22c55e;
  637. }
  638. .cc-check__mark {
  639. font-size: 22rpx;
  640. line-height: 1;
  641. color: #fff;
  642. font-weight: 700;
  643. }
  644. .state {
  645. padding: 80rpx 24rpx;
  646. text-align: center;
  647. color: #999;
  648. font-size: 28rpx;
  649. }
  650. .state-loading {
  651. display: flex;
  652. flex-direction: column;
  653. align-items: center;
  654. justify-content: center;
  655. gap: 28rpx;
  656. min-height: 50vh;
  657. padding: 96rpx 32rpx;
  658. box-sizing: border-box;
  659. }
  660. .pulse-dots {
  661. display: flex;
  662. align-items: center;
  663. gap: 16rpx;
  664. }
  665. .pulse-dot {
  666. width: 16rpx;
  667. height: 16rpx;
  668. border-radius: 50%;
  669. background: var(--brand-green, #22c55e);
  670. animation: oa-pulse-bounce 1.2s ease-in-out infinite;
  671. }
  672. .pulse-dot:nth-child(2) {
  673. animation-delay: 0.15s;
  674. opacity: 0.75;
  675. }
  676. .pulse-dot:nth-child(3) {
  677. animation-delay: 0.3s;
  678. opacity: 0.55;
  679. }
  680. .state-loading__text {
  681. font-size: 26rpx;
  682. font-weight: 500;
  683. color: var(--text-muted, #5f7a6d);
  684. line-height: 1.4;
  685. }
  686. @keyframes oa-pulse-bounce {
  687. 0%,
  688. 80%,
  689. 100% {
  690. transform: translateY(0);
  691. }
  692. 40% {
  693. transform: translateY(-12rpx);
  694. }
  695. }
  696. </style>