index.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  1. <template>
  2. <view class="page">
  3. <view class="toolbar">
  4. <view class="toolbar-row">
  5. <view class="search-box" :class="{ 'is-focus': searchFocused }">
  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="onSearchInput"
  14. @confirm="onSearchConfirm"
  15. @focus="searchFocused = true"
  16. @blur="searchFocused = 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. </view>
  24. <view v-if="loading && !list.length" class="state state-loading">
  25. <view class="pulse-dots" aria-hidden="true">
  26. <view class="pulse-dot" />
  27. <view class="pulse-dot" />
  28. <view class="pulse-dot" />
  29. </view>
  30. <text class="state-loading__text">加载我发起的…</text>
  31. </view>
  32. <view v-else-if="!list.length" class="oa-task-list__empty">暂无发起记录</view>
  33. <view v-else class="oa-task-list">
  34. <OaTaskCard
  35. v-for="row in list"
  36. :key="row.id"
  37. :title="cardTitle(row)"
  38. :status-text="statusText(row.instanceStatus || row.status)"
  39. :status-tone="statusTone(row.instanceStatus || row.status)"
  40. :meta-lines="metaLines(row)"
  41. :actions="rowActions(row)"
  42. @open="openView(row)"
  43. @action="onCardAction($event, row)"
  44. />
  45. <view v-if="loadingMore" class="oa-task-list__end">加载更多…</view>
  46. <view v-else-if="!hasMore" class="oa-task-list__end">已经到底了</view>
  47. </view>
  48. </view>
  49. </template>
  50. <script>
  51. import OaTaskCard from '../../../components/OaTaskCard.vue'
  52. import { fetchOaTasks } from '../../../utils/oa-api.js'
  53. import { forceRelogin, getToken } from '../../../utils/auth.js'
  54. import { stashOaDetailTitle, stashOaStartTitle } from '../../../utils/oa-h5.js'
  55. const PAGE_SIZE = 20
  56. const COPY_RESTART_STATUSES = ['returned', 'cancelled', 'rejected']
  57. const STATUS_MAP = {
  58. pending: '待处理',
  59. running: '进行中',
  60. approved: '已同意',
  61. rejected: '已拒绝',
  62. completed: '已完成',
  63. cancelled: '已撤销',
  64. done: '已结束',
  65. returned: '已打回',
  66. transferred: '已转交'
  67. }
  68. export default {
  69. components: { OaTaskCard },
  70. data() {
  71. return {
  72. appName: '',
  73. searchFocused: false,
  74. list: [],
  75. page: 1,
  76. total: 0,
  77. loading: false,
  78. loadingMore: false,
  79. hasMore: true,
  80. _searchTimer: null
  81. }
  82. },
  83. onShow() {
  84. if (!getToken()) {
  85. forceRelogin('请先登录')
  86. return
  87. }
  88. this.loadList(true)
  89. },
  90. onPullDownRefresh() {
  91. this.loadList(true)
  92. .catch(() => {})
  93. .finally(() => {
  94. uni.stopPullDownRefresh()
  95. })
  96. },
  97. onReachBottom() {
  98. if (!this.hasMore || this.loading || this.loadingMore) return
  99. this.loadList(false)
  100. },
  101. onUnload() {
  102. if (this._searchTimer) {
  103. clearTimeout(this._searchTimer)
  104. this._searchTimer = null
  105. }
  106. },
  107. methods: {
  108. cardTitle(row) {
  109. return (row && (row.appName || row.title)) || '-'
  110. },
  111. statusText(s) {
  112. if (!s) return '-'
  113. return STATUS_MAP[s] || s
  114. },
  115. statusTone(s) {
  116. if (s === 'pending' || s === 'running') return 'warn'
  117. if (s === 'approved' || s === 'completed' || s === 'done') return 'success'
  118. if (s === 'rejected' || s === 'cancelled' || s === 'returned') return 'danger'
  119. return 'info'
  120. },
  121. metaLines(row) {
  122. return [
  123. {
  124. label: '申请时间',
  125. value: (row && row.applyTime) || '-'
  126. },
  127. {
  128. label: '当前节点',
  129. value: (row && row.currentNodeName) || '-'
  130. },
  131. {
  132. label: '当前审批人',
  133. value: (row && row.currentApproverNames) || '-'
  134. }
  135. ]
  136. },
  137. rowActions(row) {
  138. const actions = [
  139. { key: 'view', label: '查看', primary: true },
  140. { key: 'print', label: '打印' }
  141. ]
  142. const status = String((row && (row.instanceStatus || row.status)) || '')
  143. const appId = String((row && row.appId) || '').trim()
  144. if (COPY_RESTART_STATUSES.includes(status) && appId) {
  145. actions.push({ key: 'copyRestart', label: '复制并重新发起' })
  146. }
  147. return actions
  148. },
  149. onSearchInput(e) {
  150. const v =
  151. e && e.detail && e.detail.value != null
  152. ? String(e.detail.value)
  153. : e && e.target
  154. ? String(e.target.value || '')
  155. : ''
  156. this.appName = v
  157. if (this._searchTimer) clearTimeout(this._searchTimer)
  158. this._searchTimer = setTimeout(() => {
  159. this.loadList(true)
  160. }, 400)
  161. },
  162. onSearchConfirm() {
  163. if (this._searchTimer) {
  164. clearTimeout(this._searchTimer)
  165. this._searchTimer = null
  166. }
  167. this.loadList(true)
  168. },
  169. clearAppName() {
  170. this.appName = ''
  171. this.onSearchConfirm()
  172. },
  173. onCardAction(act, row) {
  174. if (!act || !row) return
  175. if (act.key === 'copyRestart') {
  176. this.openCopyRestart(row)
  177. return
  178. }
  179. if (act.key === 'print') {
  180. this.openView(row, { autoPrint: true })
  181. return
  182. }
  183. this.openView(row)
  184. },
  185. openView(row, opts) {
  186. if (!row) return
  187. // mine 行是实例:Web 用 row.id;兼容 instanceId
  188. const instanceId = String(row.instanceId || row.id || '').trim()
  189. if (!instanceId) {
  190. uni.showToast({ title: '缺少实例 ID', icon: 'none' })
  191. return
  192. }
  193. const autoPrint = !!(opts && opts.autoPrint)
  194. stashOaDetailTitle(row.appName || row.title)
  195. uni.navigateTo({
  196. url:
  197. '/pages/oa/webview/index?instanceId=' +
  198. encodeURIComponent(instanceId) +
  199. '&mode=view' +
  200. (autoPrint ? '&autoPrint=1' : '')
  201. })
  202. },
  203. openCopyRestart(row) {
  204. if (!row) return
  205. const appId = String(row.appId || '').trim()
  206. const fromInstanceId = String(row.instanceId || row.id || '').trim()
  207. if (!appId || !fromInstanceId) {
  208. uni.showToast({ title: '无法复制:缺少应用或实例', icon: 'none' })
  209. return
  210. }
  211. stashOaStartTitle(row.appName || row.title)
  212. uni.navigateTo({
  213. url:
  214. '/pages/oa/webview/index?appId=' +
  215. encodeURIComponent(appId) +
  216. '&fromInstanceId=' +
  217. encodeURIComponent(fromInstanceId)
  218. })
  219. },
  220. async loadList(reset) {
  221. if (!getToken()) {
  222. forceRelogin('请先登录')
  223. return
  224. }
  225. const isReset = !!reset
  226. if (isReset) {
  227. this.page = 1
  228. this.hasMore = true
  229. this.loading = true
  230. } else {
  231. this.loadingMore = true
  232. }
  233. try {
  234. const res = await fetchOaTasks('mine', {
  235. appName: (this.appName || '').trim() || undefined,
  236. page: this.page,
  237. pageSize: PAGE_SIZE
  238. })
  239. const rows = (res && res.list) || []
  240. const total = (res && res.total) != null ? Number(res.total) : 0
  241. this.total = total
  242. if (isReset) this.list = rows
  243. else this.list = this.list.concat(rows)
  244. const loaded = this.list.length
  245. this.hasMore = loaded < total && rows.length > 0
  246. if (rows.length > 0) this.page += 1
  247. } catch (e) {
  248. if (isReset) this.list = []
  249. uni.showToast({
  250. title: (e && e.message) || '加载失败',
  251. icon: 'none'
  252. })
  253. } finally {
  254. this.loading = false
  255. this.loadingMore = false
  256. }
  257. }
  258. }
  259. }
  260. </script>
  261. <style scoped>
  262. .page {
  263. min-height: 100vh;
  264. padding-bottom: 48rpx;
  265. background: #f5f6f8;
  266. box-sizing: border-box;
  267. }
  268. .toolbar {
  269. position: sticky;
  270. top: 0;
  271. z-index: 10;
  272. background: #fff;
  273. padding-bottom: 16rpx;
  274. border-bottom: 1rpx solid #f1f5f9;
  275. }
  276. .toolbar-row {
  277. display: flex;
  278. align-items: center;
  279. gap: 16rpx;
  280. padding: 20rpx 24rpx 0;
  281. box-sizing: border-box;
  282. }
  283. .search-box {
  284. flex: 1;
  285. min-width: 0;
  286. display: flex;
  287. align-items: center;
  288. gap: 16rpx;
  289. height: 72rpx;
  290. padding: 0 24rpx;
  291. border-radius: 20rpx;
  292. border: 1rpx solid #e8eaed;
  293. background: #fafafa;
  294. box-sizing: border-box;
  295. transition:
  296. border-color 0.15s,
  297. background 0.15s,
  298. box-shadow 0.15s;
  299. }
  300. .search-box.is-focus {
  301. border-color: #cbd5e1;
  302. background: #fff;
  303. box-shadow: 0 0 0 6rpx rgba(148, 163, 184, 0.18);
  304. }
  305. .search-ico {
  306. position: relative;
  307. width: 26rpx;
  308. height: 26rpx;
  309. border: 3rpx solid #94a3b8;
  310. border-radius: 50%;
  311. box-sizing: border-box;
  312. flex-shrink: 0;
  313. }
  314. .search-ico::after {
  315. content: '';
  316. position: absolute;
  317. width: 12rpx;
  318. height: 3rpx;
  319. right: -8rpx;
  320. bottom: -5rpx;
  321. background: #94a3b8;
  322. border-radius: 3rpx;
  323. transform: rotate(45deg);
  324. }
  325. .search-input {
  326. flex: 1;
  327. height: 72rpx;
  328. font-size: 28rpx;
  329. color: #1f2937;
  330. min-width: 0;
  331. }
  332. .search-ph {
  333. color: #b0b7c3;
  334. }
  335. .search-clear {
  336. display: flex;
  337. align-items: center;
  338. justify-content: center;
  339. width: 40rpx;
  340. height: 40rpx;
  341. border-radius: 50%;
  342. background: #e5e7eb;
  343. flex-shrink: 0;
  344. }
  345. .search-clear__x {
  346. font-size: 28rpx;
  347. line-height: 1;
  348. color: #6b7280;
  349. margin-top: -2rpx;
  350. }
  351. .state {
  352. padding: 80rpx 24rpx;
  353. text-align: center;
  354. color: #999;
  355. font-size: 28rpx;
  356. }
  357. .state-loading {
  358. display: flex;
  359. flex-direction: column;
  360. align-items: center;
  361. justify-content: center;
  362. gap: 28rpx;
  363. min-height: 50vh;
  364. padding: 96rpx 32rpx;
  365. box-sizing: border-box;
  366. }
  367. .pulse-dots {
  368. display: flex;
  369. align-items: center;
  370. gap: 16rpx;
  371. }
  372. .pulse-dot {
  373. width: 16rpx;
  374. height: 16rpx;
  375. border-radius: 50%;
  376. background: var(--brand-green, #22c55e);
  377. animation: oa-pulse-bounce 1.2s ease-in-out infinite;
  378. }
  379. .pulse-dot:nth-child(2) {
  380. animation-delay: 0.15s;
  381. opacity: 0.75;
  382. }
  383. .pulse-dot:nth-child(3) {
  384. animation-delay: 0.3s;
  385. opacity: 0.55;
  386. }
  387. .state-loading__text {
  388. font-size: 26rpx;
  389. font-weight: 500;
  390. color: var(--text-muted, #5f7a6d);
  391. line-height: 1.4;
  392. }
  393. @keyframes oa-pulse-bounce {
  394. 0%,
  395. 80%,
  396. 100% {
  397. transform: translateY(0);
  398. }
  399. 40% {
  400. transform: translateY(-12rpx);
  401. }
  402. }
  403. </style>