zhangwencui
2026-11-02 dab35bff798cfce2ddf7e8a67e4e1cdb144f5b4c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
<script setup lang="ts">
  import { computed, ref } from "vue";
  import { onShow, onPullDownRefresh } from "@dcloudio/uni-app";
  import { getSimpleDictDataList, type DictData } from "../../../../api/crm";
  import {
    getCategorySimpleList,
    getSimpleProcessDefinitionList,
    getTaskDonePage,
    withdrawTask,
    type BpmCategory,
    type BpmProcessDefinition,
    type BpmTask,
  } from "../../../../api/bpm";
  import { useAuth } from "../../../../stores/auth";
  import { DICT, confirmModal, dateTimeText, dictLabel, durationText, summaryLines, toast } from "../../shared";
  import PickerSheet from "../../../mes/process-design/components/PickerSheet.vue";
  import type { SheetOption } from "../../../mes/process-design/shared";
  import { statusToneClass } from "../../../../utils/status";
  import { reverseApprovePurchaseOrder } from "../../../../api/purchase";
 
  const { hasPermission, isLoggedIn, state } = useAuth();
 
  const keyword = ref("");
  const rows = ref<BpmTask[]>([]);
  const dicts = ref<DictData[]>([]);
  const categories = ref<BpmCategory[]>([]);
  const definitions = ref<BpmProcessDefinition[]>([]);
  const pageNo = ref(1);
  const total = ref(0);
  const loading = ref(false);
  const loadError = ref("");
 
  /** web 端已办页三个按钮均未配置 auth,此处保留权限位定义以对齐可扩展性 */
  const canQuery = computed(() => hasPermission("bpm:task:query"));
  const hasMore = computed(() => rows.value.length < total.value);
 
  const filter = ref<{
    processDefinitionKey?: string;
    category?: string;
    status?: number;
  }>({});
 
  const statusFilterText = computed(() =>
    filter.value.status === undefined
      ? "全部状态"
      : dictLabel(dicts.value, DICT.BPM_TASK_STATUS, filter.value.status, "全部状态"),
  );
 
  const definitionFilterText = computed(() => {
    if (!filter.value.processDefinitionKey) return "全部流程";
    return (
      definitions.value.find(
        item => String(item.key) === String(filter.value.processDefinitionKey),
      )?.name ?? "全部流程"
    );
  });
 
  const categoryFilterText = computed(() => {
    if (!filter.value.category) return "全部分类";
    return categories.value.find(item => item.code === filter.value.category)?.name ?? "全部分类";
  });
 
  const statusOptions = ref<SheetOption[]>([]);
  const definitionOptions = ref<SheetOption[]>([]);
  const categoryOptions = ref<SheetOption[]>([]);
 
  const showStatus = ref(false);
  const showDefinition = ref(false);
  const showCategory = ref(false);
 
  function openStatus() {
    statusOptions.value = [
      { key: "", label: "全部状态" },
      ...dicts.value
        .filter(item => item.dictType === DICT.BPM_TASK_STATUS)
        .map(item => ({ key: item.value, label: item.label })),
    ];
    showStatus.value = true;
  }
 
  function openDefinition() {
    definitionOptions.value = [
      { key: "", label: "全部流程" },
      ...definitions.value.map(item => ({ key: item.key as string, label: item.name || "--" })),
    ];
    showDefinition.value = true;
  }
 
  function openCategory() {
    categoryOptions.value = [
      { key: "", label: "全部分类" },
      ...categories.value.map(item => ({ key: item.code as string, label: item.name || "--" })),
    ];
    showCategory.value = true;
  }
 
  function onStatusPicked(keys: Array<number | string>) {
    const key = keys[0];
    filter.value.status = key === undefined || key === "" ? undefined : Number(key);
    load(true);
  }
 
  function onDefinitionPicked(keys: Array<number | string>) {
    const key = keys[0];
    filter.value.processDefinitionKey =
      key === undefined || key === "" ? undefined : String(key);
    load(true);
  }
 
  function onCategoryPicked(keys: Array<number | string>) {
    const key = keys[0];
    filter.value.category = key === undefined || key === "" ? undefined : String(key);
    load(true);
  }
 
  function resetFilter() {
    filter.value = {};
    keyword.value = "";
    load(true);
  }
 
  function statusText(row: BpmTask) {
    return dictLabel(dicts.value, DICT.BPM_TASK_STATUS, row.status, "--");
  }
  function statusClass(row: BpmTask) {
    return statusToneClass(row.status, statusText(row));
  }
 
  /** 与 web 端 isPurchaseOrderApprove 一致:采购订单审批走反审批,其余走撤回 */
  function isPurchaseOrderApprove(row: BpmTask) {
    return String(row.processInstance?.processDefinitionId || "").startsWith(
      "purchase_order_approve",
    );
  }
 
  async function load(reset = false) {
    if (!state.accessToken || loading.value) return;
    if (reset) {
      pageNo.value = 1;
      rows.value = [];
    }
    loading.value = true;
    loadError.value = "";
    try {
      if (!dicts.value.length) dicts.value = await getSimpleDictDataList(state.accessToken);
      if (!categories.value.length) {
        categories.value = (await getCategorySimpleList(state.accessToken)) || [];
      }
      if (!definitions.value.length) {
        definitions.value = (await getSimpleProcessDefinitionList(state.accessToken))?.list || [];
      }
      const data = await getTaskDonePage(state.accessToken, {
        pageNo: pageNo.value,
        pageSize: 15,
        name: keyword.value.trim() || undefined,
        processDefinitionKey: filter.value.processDefinitionKey,
        category: filter.value.category,
        status: filter.value.status,
      });
      total.value = data.total;
      rows.value = reset ? data.list : [...rows.value, ...data.list];
      pageNo.value += 1;
    } catch (error) {
      loadError.value = error instanceof Error ? error.message : "已办任务加载失败";
    } finally {
      loading.value = false;
    }
  }
 
  /** 历史:进入流程详情 */
  function goHistory(row: BpmTask) {
    const processInstanceId = row.processInstance?.id ?? row.processInstanceId;
    if (!processInstanceId || !row.id) {
      toast("该任务缺少流程编号");
      return;
    }
    uni.navigateTo({
      url: `/pages/bpm/process-instance/detail?id=${encodeURIComponent(
        String(processInstanceId),
      )}&taskId=${encodeURIComponent(String(row.id))}`,
    });
  }
 
  /** 撤回任务 */
  async function handleWithdraw(row: BpmTask) {
    if (!row.id) return;
    const ok = await confirmModal("确定要撤回该任务吗?");
    if (!ok) return;
    try {
      await withdrawTask(state.accessToken as string, String(row.id));
      toast("已撤回");
      load(true);
    } catch (error) {
      toast(error instanceof Error ? error.message : "撤回失败");
    }
  }
 
  /** 采购订单反审批:对齐 web 端,按流程实例回退采购订单审批 */
  async function reverseApprove(row: BpmTask) {
    const processInstanceId = row.processInstance?.id ?? row.processInstanceId;
    if (!state.accessToken || !processInstanceId) return toast("该任务缺少流程编号");
    const ok = await confirmModal("确定要反审批该采购订单吗?订单将重新进入待办审批");
    if (!ok) return;
    try {
      await reverseApprovePurchaseOrder(state.accessToken, processInstanceId);
      toast("反审批成功");
      load(true);
    } catch (error) {
      toast(error instanceof Error ? error.message : "反审批失败");
    }
  }
 
  onShow(() => {
    load(true);
  });
 
  onPullDownRefresh(async () => {
    await load(true);
    uni.stopPullDownRefresh();
  });
</script>
 
<template>
  <view class="page">
    <template v-if="isLoggedIn">
      <view class="search">
        <input
          v-model="keyword"
          placeholder="搜索任务名称"
          confirm-type="search"
          @confirm="load(true)"
        />
        <text v-if="keyword" @click="keyword = ''; load(true)">清除</text>
      </view>
 
      <view class="filters">
        <text class="filter" @click="openStatus">{{ statusFilterText }}</text>
        <text class="filter" @click="openDefinition">{{ definitionFilterText }}</text>
        <text class="filter" @click="openCategory">{{ categoryFilterText }}</text>
        <text class="reset" @click="resetFilter">重置</text>
      </view>
 
      <scroll-view class="content" scroll-y lower-threshold="80" @scrolltolower="hasMore && load()">
        <view v-if="loadError" class="state error" @click="load(true)">{{ loadError }},点击重试</view>
        <view v-else-if="rows.length">
          <view v-for="row in rows" :key="row.id" class="row">
            <view class="head">
              <view class="head-main">
                <text class="name">{{ row.processInstance?.name || row.name || '未命名流程' }}</text>
                <text class="code">当前任务:{{ row.name || '--' }}</text>
              </view>
              <text :class="['status', statusClass(row)]">{{ statusText(row) }}</text>
            </view>
            <view class="grid">
              <text class="summary">{{ summaryLines(row.processInstance?.summary) }}</text>
              <text>发起人:{{ row.processInstance?.startUser?.nickname || '--' }}</text>
              <text>发起时间:{{ dateTimeText(row.processInstance?.createTime) }}</text>
              <text>任务开始:{{ dateTimeText(row.createTime) }}</text>
              <text>任务结束:{{ dateTimeText(row.endTime) }}</text>
              <text>审批建议:{{ row.reason || '--' }}</text>
              <text>耗时:{{ durationText(row.durationInMillis) }}</text>
              <text>流程编号:{{ row.processInstance?.id || row.processInstanceId || '--' }}</text>
              <text>任务编号:{{ row.id || '--' }}</text>
            </view>
            <view class="actions">
              <text
                v-if="isPurchaseOrderApprove(row)"
                class="action danger"
                @click="reverseApprove(row)"
              >
                反审批
              </text>
              <text v-else class="action danger" @click="handleWithdraw(row)">撤回</text>
              <text class="action" @click="goHistory(row)">历史</text>
            </view>
          </view>
          <text class="more">{{ loading ? '加载中...' : hasMore ? '上拉加载更多' : '没有更多了' }}</text>
        </view>
        <view v-else class="state">{{ loading ? '加载中...' : '暂无已办任务' }}</view>
      </scroll-view>
    </template>
    <view v-else class="state">登录后查看已办任务</view>
 
    <PickerSheet
      v-model:show="showStatus"
      title="审批状态"
      :items="statusOptions"
      :selected-keys="filter.status === undefined ? [] : [filter.status]"
      @confirm="onStatusPicked"
    />
    <PickerSheet
      v-model:show="showDefinition"
      title="所属流程"
      :items="definitionOptions"
      :selected-keys="filter.processDefinitionKey ? [filter.processDefinitionKey] : []"
      @confirm="onDefinitionPicked"
    />
    <PickerSheet
      v-model:show="showCategory"
      title="流程分类"
      :items="categoryOptions"
      :selected-keys="filter.category ? [filter.category] : []"
      @confirm="onCategoryPicked"
    />
  </view>
</template>
 
<style lang="scss" scoped>
  .page{height:100vh;display:flex;flex-direction:column;padding:0 30rpx;box-sizing:border-box;overflow:hidden;background:#f3f6fb}
  .search{display:flex;align-items:center;height:80rpx;margin:20rpx 0 12rpx;padding:0 20rpx;background:#fff;border-radius:16rpx;box-shadow:0 8rpx 20rpx rgba(43,69,107,.06)}
  .search input{flex:1;font-size:27rpx}
  .search text{color:var(--color-primary, #1769d8);font-size:24rpx}
  .filters{display:flex;align-items:center;gap:12rpx;margin-bottom:16rpx;overflow:hidden}
  .filter{flex:none;max-width:210rpx;overflow:hidden;padding:10rpx 20rpx;color:#3d5069;font-size:24rpx;text-overflow:ellipsis;white-space:nowrap;background:#fff;border:1rpx solid #dbe4ef;border-radius:999rpx}
  .reset{flex:none;margin-left:auto;color:var(--color-primary, #1769d8);font-size:24rpx}
  .content{height:0;flex:1;padding-bottom:60rpx;box-sizing:border-box}
  .row{padding:24rpx 22rpx 18rpx;margin-bottom:18rpx;background:#fff;border-radius:16rpx;box-shadow:0 10rpx 24rpx rgba(43,69,107,.08)}
  .head{display:flex;align-items:flex-start;justify-content:space-between;gap:16rpx}
  .head-main{display:flex;min-width:0;flex:1;flex-direction:column;gap:8rpx}
  .head-main .code{color:#78879b;font-size:23rpx}
  .name{overflow:hidden;color:#20334c;font-size:30rpx;font-weight:600;text-overflow:ellipsis;white-space:nowrap}
  .status{flex:none;padding:8rpx 16rpx;color:#3d5069;font-size:22rpx;background:#eef2f7;border:1rpx solid #cfdae7;border-radius:999rpx}
  .grid{display:grid;gap:10rpx;padding:16rpx 0;margin-top:16rpx;color:#66778c;font-size:23rpx;border-top:1rpx solid #edf1f6}
  .grid text{overflow:hidden;text-overflow:ellipsis}
  .grid .summary{white-space:normal;word-break:break-all}
  .actions{display:flex;justify-content:flex-end;gap:14rpx;padding-top:14rpx;border-top:1rpx solid #edf1f6}
  .action{padding:8rpx 22rpx;color:var(--color-primary, #1769d8);font-size:24rpx;background:#eef4fd;border-radius:999rpx}
  .action.danger{color:#d9534f;background:#fdeeee}
  .more{display:block;padding:16rpx 0 6rpx;color:#93a1b3;font-size:22rpx;text-align:center}
  .state{padding:60rpx 20rpx;color:#8795a8;font-size:25rpx;text-align:center}
  .state.error{color:#c65050}
</style>