<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>
|