merge: t19 业务完成确认单与审批引擎起步

This commit is contained in:
2026-10-30 20:29:16 +08:00
9 changed files with 1794 additions and 2 deletions
+57
View File
@@ -0,0 +1,57 @@
import request from '@/utils/request';
import type { ApiResult } from '@/api';
import type { ApprovalAttachment } from '../model';
import { TENANT_ID, THINK_API_URL } from '@/config/setting';
/**
* 上传附件:走审批域端点,header 带登录态与租户
*/
export function uploadAttachment(
instanceId: number,
filePath: string,
category: string
): Promise<ApprovalAttachment> {
return new Promise((resolve, reject) => {
const token = uni.getStorageSync('access_token');
uni.uploadFile({
url:
THINK_API_URL +
'/approval/attachment/upload?instanceId=' +
instanceId,
name: 'file',
filePath,
formData: { category },
header: {
TenantId: TENANT_ID,
...(token ? { Authorization: token } : {})
},
success: res => {
try {
const body =
typeof res.data === 'string' ? JSON.parse(res.data) : res.data;
if (body.code === 0 && body.data) {
resolve(body.data as ApprovalAttachment);
return;
}
reject(new Error(body.message || '上传失败'));
} catch (err) {
reject(new Error('上传失败'));
}
},
fail: () => reject(new Error('上传失败'))
});
});
}
/**
* 逻辑删除附件
*/
export async function deleteAttachment(id: number) {
const res = await request.delete<ApiResult<unknown>>(
THINK_API_URL + '/approval/attachment/' + id
);
if (res.data.code === 0) {
return true;
}
return Promise.reject(new Error(res.data.message));
}
+63
View File
@@ -0,0 +1,63 @@
import request from '@/utils/request';
import type { ApiResult, PageResult } from '@/api';
import type {
ApprovalCompletionForm,
ApprovalInstance,
ApprovalItem,
ApprovalMineParam
} from '../model';
import { THINK_API_URL } from '@/config/setting';
/**
* 新建或保存业务完成确认单草稿,返回实例id
*/
export async function saveCompletion(data: ApprovalCompletionForm) {
const res = await request.post<ApiResult<number>>(
THINK_API_URL + '/approval/instance/completion',
data
);
if (res.data.code === 0) {
return res.data.data as number;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 提交或重提
*/
export async function submitInstance(id: number) {
const res = await request.post<ApiResult<ApprovalInstance>>(
THINK_API_URL + '/approval/instance/' + id + '/submit'
);
if (res.data.code === 0) {
return res.data;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 单据详情
*/
export async function getInstance(id: number) {
const res = await request.get<ApiResult<ApprovalInstance>>(
THINK_API_URL + '/approval/instance/' + id
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 我发起的
*/
export async function pageMine(params: ApprovalMineParam) {
const res = await request.get<ApiResult<PageResult<ApprovalItem>>>(
THINK_API_URL + '/approval/mine/page',
{ params }
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
+149
View File
@@ -0,0 +1,149 @@
import type { PageParam } from '@/api';
/**
* 审批附件
*/
export interface ApprovalAttachment {
id: number;
fileId: number;
name: string;
url: string;
length: number;
category: string;
categoryName: string;
uploaderId: number;
uploaderName?: string;
createTime?: string;
}
/**
* 处理记录
*/
export interface ApprovalRecord {
id: number;
taskId?: number;
nodeKey?: string;
nodeName?: string;
operatorId: number;
operatorName?: string;
action: string;
actionName: string;
opinion?: string;
round: number;
createTime?: string;
}
/**
* 当前轮存档确认状态
*/
export interface ApprovalConfirm {
userId: number;
userName?: string;
status: string;
statusName: string;
handleTime?: string;
}
/**
* 当前用户待办任务
*/
export interface ApprovalMyTask {
id: number;
nodeKey: string;
nodeType: string;
status: string;
}
/**
* 详情动作权限
*/
export interface ApprovalPermissions {
canEdit: boolean;
canSubmit: boolean;
canWithdraw: boolean;
canApprove: boolean;
canReject: boolean;
canConfirm: boolean;
canArchive: boolean;
canDispatch: boolean;
canAssign: boolean;
}
/**
* 列表行
*/
export interface ApprovalItem {
id: number;
bizNo?: string;
docType: string;
docTypeName: string;
status: string;
statusName: string;
title?: string;
submitTime?: string;
currentNodeName?: string;
reporterId: number;
reporterName?: string;
reportOrgName?: string;
acceptOrgName?: string;
archiveTime?: string;
}
/**
* 单据详情
*/
export interface ApprovalInstance extends ApprovalItem {
flowCode: string;
round: number;
reportOrgId?: number;
acceptOrgId?: number;
currentNodeKey?: string;
currentNodeType?: string;
approveTime?: string;
archivistId?: number;
archivistName?: string;
name?: string;
implementOrg?: string;
fee?: number;
serviceContent?: string;
content?: string;
remark?: string;
dispatchTargetId?: number;
dispatchTargetName?: string;
attachments: ApprovalAttachment[];
records: ApprovalRecord[];
confirms: ApprovalConfirm[];
myTask?: ApprovalMyTask | null;
permissions: ApprovalPermissions;
}
/**
* 业务完成确认单草稿参数
*/
export interface ApprovalCompletionForm {
id?: number;
name: string;
implementOrg: string;
fee: number | string;
serviceContent: string;
}
/**
* 我发起的查询参数
*/
export interface ApprovalMineParam extends PageParam {
status?: string;
docType?: string;
}
/**
* 组织
*/
export interface ApprovalOrganization {
organizationId: number;
parentId?: number;
organizationName: string;
organizationFullName?: string;
organizationCode?: string;
leaderId?: number;
}
+16
View File
@@ -0,0 +1,16 @@
import request from '@/utils/request';
import type { ApiResult } from '@/api';
import { THINK_API_URL } from '@/config/setting';
/**
* 待办数量(个人中心红点)
*/
export async function getTodoCount() {
const res = await request.get<ApiResult<number>>(
THINK_API_URL + '/approval/todo/count'
);
if (res.data.code === 0 && res.data.data !== undefined) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
+441
View File
@@ -0,0 +1,441 @@
<template>
<view class="detail-page">
<view v-if="detail" class="content">
<uni-card :border="false" :padding="'16px'">
<view class="head">
<text class="title">{{ detail.title || detail.name || '单据详情' }}</text>
<text class="status" :class="'status-' + detail.status">
{{ detail.statusName }}
</text>
</view>
<view class="rows">
<view class="row">
<text class="label">单号</text>
<text class="value">{{ detail.bizNo || '—' }}</text>
</view>
<view class="row">
<text class="label">单据类型</text>
<text class="value">{{ detail.docTypeName }}</text>
</view>
<view class="row">
<text class="label">报送人</text>
<text class="value">{{ detail.reporterName || '—' }}</text>
</view>
<view class="row">
<text class="label">报送部门</text>
<text class="value">{{ detail.reportOrgName || '—' }}</text>
</view>
<view class="row">
<text class="label">当前节点</text>
<text class="value">{{ detail.currentNodeName || '—' }}</text>
</view>
<view class="row">
<text class="label">提交时间</text>
<text class="value">{{ formatTime(detail.submitTime) }}</text>
</view>
</view>
</uni-card>
<uni-card :border="false" :padding="'16px'" title="单据信息">
<view v-if="detail.docType === 'completion'" class="rows">
<view class="row">
<text class="label">名称</text>
<text class="value">{{ detail.name }}</text>
</view>
<view class="row">
<text class="label">实施单位</text>
<text class="value">{{ detail.implementOrg }}</text>
</view>
<view class="row">
<text class="label">费用(元)</text>
<text class="value">{{ formatFee(detail.fee) }}</text>
</view>
<view class="row">
<text class="label">服务内容</text>
<text class="value multiline">{{ detail.serviceContent }}</text>
</view>
</view>
<view v-else class="rows">
<view class="row">
<text class="label">受理部门</text>
<text class="value">{{ detail.acceptOrgName || '—' }}</text>
</view>
<view class="row">
<text class="label">标题</text>
<text class="value">{{ detail.title }}</text>
</view>
<view class="row">
<text class="label">内容</text>
<text class="value multiline">{{ detail.content }}</text>
</view>
<view class="row">
<text class="label">备注</text>
<text class="value multiline">{{ detail.remark || '—' }}</text>
</view>
</view>
</uni-card>
<uni-card :border="false" :padding="'16px'" title="附件">
<view
v-for="item in detail.attachments"
:key="item.id"
class="attachment"
@click="openAttachment(item.url)"
>
<view class="attachment-main">
<text class="attachment-name">{{ item.name }}</text>
<text class="attachment-meta">
{{ item.categoryName }} · {{ formatSize(item.length) }}
</text>
</view>
<text class="link">查看</text>
</view>
<view v-if="detail.attachments.length === 0" class="empty">暂无附件</view>
</uni-card>
<uni-card :border="false" :padding="'16px'" title="处理记录">
<view v-for="item in detail.records" :key="item.id" class="timeline-item">
<view class="timeline-dot"></view>
<view class="timeline-body">
<view class="timeline-head">
<text class="timeline-operator">{{ item.operatorName || '—' }}</text>
<text class="timeline-action">{{ item.actionName }}</text>
</view>
<text class="timeline-node">{{ item.nodeName || '提交' }}</text>
<text v-if="item.opinion" class="timeline-opinion">意见:{{ item.opinion }}</text>
<text class="timeline-time">{{ formatTime(item.createTime) }}</text>
</view>
</view>
<view v-if="detail.records.length === 0" class="empty">暂无记录</view>
</uni-card>
<view v-if="detail.permissions.canEdit || detail.permissions.canSubmit" class="actions">
<button
v-if="detail.permissions.canEdit"
class="action-button ghost"
@click="editDraft"
>
编辑
</button>
<button
v-if="detail.permissions.canSubmit"
class="action-button primary"
:loading="submitting"
@click="submitDraft"
>
提交
</button>
</view>
</view>
<view v-else class="loading">
<text>{{ loading ? '加载中...' : '单据不存在' }}</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import { getInstance, submitInstance } from '@/api/approval/instance';
import type { ApprovalInstance } from '@/api/approval/model';
import { showError } from '@/utils/app';
const detail = ref<ApprovalInstance | null>(null);
const loading = ref(false);
const submitting = ref(false);
const instanceId = ref(0);
onLoad(options => {
instanceId.value = Number(options?.id) || 0;
load();
});
async function load() {
if (!instanceId.value) {
return;
}
loading.value = true;
try {
detail.value = await getInstance(instanceId.value);
} catch (error: any) {
showError(error?.message || '加载失败', null);
} finally {
loading.value = false;
}
}
function editDraft() {
uni.navigateTo({
url:
'/packageApproval/pages/form-completion?mode=edit&id=' + instanceId.value
});
}
function submitDraft() {
uni.showModal({
title: '提交确认',
content: '提交后字段与附件将锁定,确认提交?',
success: async result => {
if (!result.confirm) {
return;
}
submitting.value = true;
try {
const response = await submitInstance(instanceId.value);
uni.showToast({
title: response.message || '提交成功',
icon: 'none'
});
await load();
} catch (error: any) {
showError(error?.message || '提交失败', null);
} finally {
submitting.value = false;
}
}
});
}
function openAttachment(url?: string) {
if (!url) {
return;
}
// #ifdef H5
window.open(url, '_blank');
// #endif
// #ifndef H5
uni.downloadFile({
url,
success: result => {
if (result.statusCode === 200) {
uni.openDocument({
filePath: result.tempFilePath,
showMenu: true
});
}
},
fail: () => uni.showToast({ title: '附件打开失败', icon: 'none' })
});
// #endif
}
function formatTime(value?: string) {
if (!value) {
return '—';
}
return value.replace('T', ' ').substring(0, 19);
}
function formatFee(value?: number) {
if (value === undefined || value === null) {
return '—';
}
return Number(value).toFixed(2);
}
function formatSize(value?: number) {
if (!value) {
return '0 B';
}
if (value < 1024) {
return value + ' B';
}
if (value < 1024 * 1024) {
return (value / 1024).toFixed(1) + ' KB';
}
return (value / 1024 / 1024).toFixed(1) + ' MB';
}
</script>
<style lang="scss">
.detail-page {
min-height: 100vh;
background-color: #f5f6f8;
padding-bottom: 180rpx;
}
.head {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 16rpx;
}
.title {
flex: 1;
font-size: 32rpx;
font-weight: 600;
color: #303133;
}
.status {
font-size: 24rpx;
padding: 4rpx 14rpx;
border-radius: 999rpx;
background: #f2f3f5;
color: #606266;
}
.status-PENDING {
background: #fff7e6;
color: #d46b08;
}
.status-REJECTED {
background: #fff1f0;
color: #cf1322;
}
.status-APPROVED {
background: #f6ffed;
color: #389e0d;
}
.status-ARCHIVED {
background: #e6f4ff;
color: #0958d9;
}
.rows {
display: flex;
flex-direction: column;
}
.row {
display: flex;
align-items: flex-start;
margin-top: 12rpx;
font-size: 26rpx;
}
.label {
width: 160rpx;
color: #909399;
flex-shrink: 0;
}
.value {
flex: 1;
color: #303133;
word-break: break-all;
}
.multiline {
white-space: pre-wrap;
line-height: 1.6;
}
.attachment {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 0;
border-bottom: 1rpx solid #f0f0f0;
}
.attachment-main {
display: flex;
flex-direction: column;
flex: 1;
}
.attachment-name {
font-size: 26rpx;
color: #303133;
word-break: break-all;
}
.attachment-meta {
margin-top: 6rpx;
font-size: 24rpx;
color: #909399;
}
.link {
font-size: 26rpx;
color: #d81e06;
margin-left: 16rpx;
}
.timeline-item {
display: flex;
padding: 12rpx 0;
}
.timeline-dot {
width: 16rpx;
height: 16rpx;
margin: 10rpx 16rpx 0 0;
border-radius: 50%;
background: #d81e06;
flex-shrink: 0;
}
.timeline-body {
flex: 1;
display: flex;
flex-direction: column;
}
.timeline-head {
display: flex;
align-items: center;
gap: 12rpx;
}
.timeline-operator {
font-size: 28rpx;
color: #303133;
font-weight: 600;
}
.timeline-action {
font-size: 24rpx;
color: #d81e06;
}
.timeline-node,
.timeline-opinion,
.timeline-time {
margin-top: 6rpx;
font-size: 24rpx;
color: #909399;
}
.empty {
padding: 24rpx 0;
text-align: center;
font-size: 26rpx;
color: #909399;
}
.actions {
display: flex;
gap: 24rpx;
padding: 24rpx 32rpx 48rpx;
}
.action-button {
flex: 1;
font-size: 30rpx;
border-radius: 12rpx;
}
.action-button.primary {
background: #d81e06;
color: #ffffff;
}
.action-button.ghost {
background: #ffffff;
color: #303133;
border: 1rpx solid #dcdfe6;
}
.loading {
padding: 160rpx 0;
text-align: center;
color: #909399;
font-size: 28rpx;
}
</style>
@@ -0,0 +1,532 @@
<template>
<view class="form-page">
<uni-card :border="false" :padding="'16px'" title="基本信息">
<view class="auto-row">
<text class="label">经办人</text>
<text class="value">{{ reporterName || '—' }}</text>
</view>
<view class="auto-row">
<text class="label">报送部门</text>
<text class="value">{{ reportOrgName || '未归属部门' }}</text>
</view>
<view class="field">
<text class="field-label">名称<text class="required">*</text></text>
<input
v-model="form.name"
class="field-input"
type="text"
maxlength="100"
placeholder="请输入名称"
/>
</view>
<view class="field">
<text class="field-label">实施单位<text class="required">*</text></text>
<input
v-model="form.implementOrg"
class="field-input"
type="text"
maxlength="100"
placeholder="请输入实施单位"
/>
</view>
<view class="field">
<text class="field-label">费用(元)<text class="required">*</text></text>
<input
v-model="form.fee"
class="field-input"
type="digit"
placeholder="0.00,最多两位小数"
/>
</view>
<view class="field">
<text class="field-label">服务内容<text class="required">*</text></text>
<textarea
v-model="form.serviceContent"
class="field-textarea"
maxlength="2000"
placeholder="请输入服务内容,最多2000字"
/>
<text class="counter">{{ form.serviceContent.length }}/2000</text>
</view>
</uni-card>
<uni-card :border="false" :padding="'16px'" title="附件">
<view class="category-row">
<text class="field-label">上传分类</text>
<view class="chips">
<view
v-for="item in categories"
:key="item.value"
class="chip"
:class="{ active: category === item.value }"
@click="category = item.value"
>
{{ item.label }}
</view>
</view>
</view>
<view v-for="item in attachments" :key="item.id" class="attachment">
<view class="attachment-main">
<text class="attachment-name">{{ item.name }}</text>
<text class="attachment-meta">
{{ item.categoryName }} · {{ formatSize(item.length) }}
</text>
</view>
<text class="delete" @click="removeAttachment(item)">删除</text>
</view>
<view v-if="attachments.length === 0" class="empty">暂无附件</view>
<button class="upload-button" :loading="uploading" @click="chooseAttachment">
上传附件({{ attachments.length }}/9)
</button>
<text class="hint">支持 jpg/jpeg/png/pdf/doc/docx/xls/xlsx,单张不超过20MB</text>
</uni-card>
<view class="footer">
<button class="footer-button ghost" :loading="saving" @click="saveDraft">
保存草稿
</button>
<button class="footer-button primary" :loading="submitting" @click="submit">
提交
</button>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import { useUserStore } from '@/store/modules/user';
import {
getInstance,
saveCompletion,
submitInstance
} from '@/api/approval/instance';
import {
deleteAttachment,
uploadAttachment
} from '@/api/approval/attachment';
import type { ApprovalAttachment } from '@/api/approval/model';
import { showError, showSuccess } from '@/utils/app';
const userStore = useUserStore();
const categories = [
{ value: 'SIGNED', label: '签字件' },
{ value: 'OTHER', label: '其他' }
];
const mode = ref<'create' | 'edit'>('create');
const instanceId = ref(0);
const form = reactive({
name: '',
implementOrg: '',
fee: '',
serviceContent: ''
});
const attachments = ref<ApprovalAttachment[]>([]);
const category = ref('SIGNED');
const saving = ref(false);
const submitting = ref(false);
const uploading = ref(false);
const reporterName = computed(
() => userStore.info?.realName || userStore.info?.nickname || ''
);
const reportOrgName = computed(() => userStore.info?.organizationName || '');
onLoad(options => {
mode.value = options?.mode === 'edit' ? 'edit' : 'create';
instanceId.value = Number(options?.id) || 0;
if (instanceId.value) {
loadDetail();
}
});
async function loadDetail() {
try {
const detail = await getInstance(instanceId.value);
if (!detail.permissions?.canEdit) {
showError('当前状态不可修改', null);
return;
}
form.name = detail.name || '';
form.implementOrg = detail.implementOrg || '';
form.fee =
detail.fee === undefined || detail.fee === null
? ''
: Number(detail.fee).toFixed(2);
form.serviceContent = detail.serviceContent || '';
attachments.value = detail.attachments || [];
} catch (error: any) {
showError(error?.message || '加载失败', null);
}
}
function validate(): string | null {
if (!form.name.trim()) {
return '请输入名称';
}
if (form.name.trim().length > 100) {
return '名称不能超过100字';
}
if (!form.implementOrg.trim()) {
return '请输入实施单位';
}
if (form.implementOrg.trim().length > 100) {
return '实施单位不能超过100字';
}
const fee = Number(form.fee);
if (!form.fee || Number.isNaN(fee)) {
return '请输入费用';
}
if (!/^\d+(\.\d{1,2})?$/.test(form.fee)) {
return '费用必须为0到99999999.99之间的两位小数';
}
if (fee < 0 || fee > 99999999.99) {
return '费用必须为0到99999999.99之间的两位小数';
}
if (!form.serviceContent.trim()) {
return '请输入服务内容';
}
if (form.serviceContent.trim().length > 2000) {
return '服务内容不能超过2000字';
}
return null;
}
async function ensureDraft(): Promise<number> {
const message = validate();
if (message) {
showError(message, null);
return 0;
}
saving.value = true;
try {
const id = await saveCompletion({
id: instanceId.value || undefined,
name: form.name.trim(),
implementOrg: form.implementOrg.trim(),
fee: form.fee,
serviceContent: form.serviceContent.trim()
});
instanceId.value = id;
mode.value = 'edit';
return id;
} catch (error: any) {
showError(error?.message || '保存失败', null);
return 0;
} finally {
saving.value = false;
}
}
async function saveDraft() {
const id = await ensureDraft();
if (!id) {
return;
}
showSuccess('草稿已保存', null);
}
async function submit() {
const id = await ensureDraft();
if (!id) {
return;
}
uni.showModal({
title: '提交确认',
content: '提交后字段与附件将锁定,确认提交?',
success: async result => {
if (!result.confirm) {
return;
}
submitting.value = true;
try {
const response = await submitInstance(id);
showSuccess(response.message || '提交成功', () => {
uni.redirectTo({
url: '/packageApproval/pages/detail?id=' + id
});
});
} catch (error: any) {
showError(error?.message || '提交失败', null);
} finally {
submitting.value = false;
}
}
});
}
function chooseAttachment() {
if (attachments.value.length >= 9) {
showError('每单最多上传9张附件', null);
return;
}
const remaining = 9 - attachments.value.length;
// #ifdef H5
(uni as any).chooseFile({
count: remaining,
extension: ['.jpg', '.jpeg', '.png', '.pdf', '.doc', '.docx', '.xls', '.xlsx'],
success: (result: any) => uploadFiles(result.tempFiles || []),
fail: () => undefined
});
// #endif
// #ifdef MP-WEIXIN
(uni as any).chooseMessageFile({
count: remaining,
type: 'file',
success: (result: any) => uploadFiles(result.tempFiles || []),
fail: () => undefined
});
// #endif
// #ifdef APP-PLUS
(uni as any).chooseImage({
count: remaining,
sizeType: ['compressed'],
sourceType: ['camera', 'album'],
success: (result: any) =>
(result.tempFilePaths || []).forEach((path: string) =>
uploadFiles([{ path, name: path.split('/').pop() || '附件' }])
),
fail: () => undefined
});
// #endif
}
async function uploadFiles(files: Array<{ path: string; name?: string; size?: number }>) {
if (!files.length) {
return;
}
const id = await ensureDraft();
if (!id) {
return;
}
uploading.value = true;
try {
for (const file of files) {
if (file.size && file.size > 20 * 1024 * 1024) {
showError('单个附件不能超过20MB', null);
continue;
}
const uploaded = await uploadAttachment(id, file.path, category.value);
attachments.value = [...attachments.value, uploaded];
}
uni.showToast({ title: '上传成功', icon: 'success' });
} catch (error: any) {
showError(error?.message || '上传失败', null);
} finally {
uploading.value = false;
}
}
function removeAttachment(item: ApprovalAttachment) {
uni.showModal({
title: '删除附件',
content: '确认删除该附件?',
success: async result => {
if (!result.confirm) {
return;
}
try {
await deleteAttachment(item.id);
attachments.value = attachments.value.filter(
attachment => attachment.id !== item.id
);
uni.showToast({ title: '已删除', icon: 'success' });
} catch (error: any) {
showError(error?.message || '删除失败', null);
}
}
});
}
function formatSize(value?: number) {
if (!value) {
return '0 B';
}
if (value < 1024) {
return value + ' B';
}
if (value < 1024 * 1024) {
return (value / 1024).toFixed(1) + ' KB';
}
return (value / 1024 / 1024).toFixed(1) + ' MB';
}
</script>
<style lang="scss">
.form-page {
min-height: 100vh;
background-color: #f5f6f8;
padding-bottom: 180rpx;
}
.auto-row {
display: flex;
align-items: center;
margin-bottom: 12rpx;
font-size: 26rpx;
}
.label {
width: 160rpx;
color: #909399;
}
.value {
flex: 1;
color: #303133;
}
.field {
margin-top: 24rpx;
}
.field-label {
display: block;
margin-bottom: 12rpx;
font-size: 26rpx;
color: #303133;
}
.required {
color: #d81e06;
margin-left: 4rpx;
}
.field-input {
height: 72rpx;
padding: 0 20rpx;
font-size: 28rpx;
background: #f7f8fa;
border-radius: 12rpx;
}
.field-textarea {
width: 100%;
height: 220rpx;
padding: 16rpx 20rpx;
font-size: 28rpx;
background: #f7f8fa;
border-radius: 12rpx;
box-sizing: border-box;
}
.counter {
display: block;
margin-top: 8rpx;
text-align: right;
font-size: 24rpx;
color: #909399;
}
.category-row {
display: flex;
align-items: center;
margin-bottom: 16rpx;
}
.chips {
display: flex;
gap: 16rpx;
}
.chip {
padding: 8rpx 24rpx;
font-size: 24rpx;
color: #606266;
background: #f2f3f5;
border-radius: 999rpx;
}
.chip.active {
color: #ffffff;
background: #d81e06;
}
.attachment {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 0;
border-bottom: 1rpx solid #f0f0f0;
}
.attachment-main {
display: flex;
flex-direction: column;
flex: 1;
}
.attachment-name {
font-size: 26rpx;
color: #303133;
word-break: break-all;
}
.attachment-meta {
margin-top: 6rpx;
font-size: 24rpx;
color: #909399;
}
.delete {
margin-left: 16rpx;
font-size: 26rpx;
color: #d81e06;
}
.empty {
padding: 24rpx 0;
text-align: center;
font-size: 26rpx;
color: #909399;
}
.upload-button {
margin-top: 16rpx;
font-size: 26rpx;
background: #f7f8fa;
color: #303133;
border: 1rpx dashed #dcdfe6;
}
.hint {
display: block;
margin-top: 12rpx;
font-size: 22rpx;
color: #909399;
}
.footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: 24rpx;
padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom));
background: #ffffff;
box-shadow: 0 -4rpx 12rpx rgba(0, 0, 0, 0.04);
}
.footer-button {
flex: 1;
font-size: 30rpx;
border-radius: 12rpx;
}
.footer-button.primary {
background: #d81e06;
color: #ffffff;
}
.footer-button.ghost {
background: #ffffff;
color: #303133;
border: 1rpx solid #dcdfe6;
}
</style>
+391
View File
@@ -0,0 +1,391 @@
<template>
<view class="approval-page">
<view class="tabs">
<view
v-for="tab in tabs"
:key="tab.key"
class="tab"
:class="{ active: activeTab === tab.key }"
@click="switchTab(tab.key)"
>
{{ tab.label }}
</view>
</view>
<view v-if="activeTab === 'mine'" class="toolbar">
<picker :range="statusLabels" :value="statusIndex" @change="onStatusChange">
<view class="filter">{{ statusLabels[statusIndex] }}</view>
</picker>
<input
v-model="keywords"
class="search"
type="text"
placeholder="搜索单号或名称"
confirm-type="search"
@confirm="reload"
/>
</view>
<view v-if="activeTab !== 'mine'" class="placeholder">
<text class="placeholder-title">{{ activeLabel }}功能建设中</text>
<text class="placeholder-desc">当前版本先开放「我发起的」</text>
</view>
<view v-else class="list">
<uni-card
v-for="item in list"
:key="item.id"
:border="false"
:padding="'14px 16px'"
class="item-card"
@click="openDetail(item.id)"
>
<view class="item-head">
<text class="item-title">{{ item.title || '未命名单据' }}</text>
<text class="status" :class="'status-' + item.status">
{{ item.statusName }}
</text>
</view>
<view class="item-row">
<text class="label">单号</text>
<text class="value">{{ item.bizNo || '—' }}</text>
</view>
<view class="item-row">
<text class="label">当前节点</text>
<text class="value">{{ item.currentNodeName || '—' }}</text>
</view>
<view class="item-row">
<text class="label">提交时间</text>
<text class="value">{{ formatTime(item.submitTime) }}</text>
</view>
</uni-card>
<view v-if="!loading && list.length === 0" class="empty">
<text>暂无单据</text>
</view>
<view v-if="loading" class="loading">
<text>加载中...</text>
</view>
<view v-if="finished && list.length > 0" class="finished">
<text>没有更多了</text>
</view>
</view>
<view v-if="activeTab === 'mine'" class="fab" @click="createDraft">
<text class="fab-text">+</text>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onLoad, onPullDownRefresh, onReachBottom, onShow } from '@dcloudio/uni-app';
import { pageMine } from '@/api/approval/instance';
import type { ApprovalItem } from '@/api/approval/model';
type TabKey = 'todo' | 'mine' | 'done';
const tabs: Array<{ key: TabKey; label: string }> = [
{ key: 'todo', label: '待办' },
{ key: 'mine', label: '我发起的' },
{ key: 'done', label: '已办' }
];
const statusOptions = [
{ value: '', label: '全部状态' },
{ value: 'DRAFT', label: '未提交' },
{ value: 'PENDING', label: '审批中' },
{ value: 'REJECTED', label: '已驳回' },
{ value: 'WITHDRAWN', label: '已撤回' },
{ value: 'APPROVED', label: '审批完成' },
{ value: 'ARCHIVED', label: '已归档' }
];
const statusLabels = statusOptions.map(option => option.label);
const activeTab = ref<TabKey>('mine');
const activeLabel = computed(
() => tabs.find(tab => tab.key === activeTab.value)?.label || ''
);
const statusIndex = ref(0);
const keywords = ref('');
const list = ref<ApprovalItem[]>([]);
const page = ref(1);
const limit = 10;
const total = ref(0);
const loading = ref(false);
const finished = ref(false);
onLoad(() => {
load(true);
});
onShow(() => {
if (activeTab.value === 'mine') {
load(true);
}
});
onPullDownRefresh(async () => {
if (activeTab.value === 'mine') {
await load(true);
}
uni.stopPullDownRefresh();
});
onReachBottom(() => {
if (activeTab.value === 'mine' && !loading.value && !finished.value) {
load(false);
}
});
function switchTab(key: TabKey) {
if (activeTab.value === key) {
return;
}
activeTab.value = key;
if (key === 'mine' && list.value.length === 0) {
load(true);
}
}
function onStatusChange(event: { detail: { value: string | number } }) {
statusIndex.value = Number(event.detail.value);
load(true);
}
async function load(reset: boolean) {
if (loading.value) {
return;
}
loading.value = true;
try {
if (reset) {
page.value = 1;
finished.value = false;
}
const result = await pageMine({
page: page.value,
limit,
keywords: keywords.value || undefined,
status: statusOptions[statusIndex.value].value || undefined
});
total.value = Number(result.count) || 0;
list.value = reset ? result.list || [] : [...list.value, ...(result.list || [])];
finished.value = list.value.length >= total.value;
if (!finished.value) {
page.value += 1;
}
} catch (error: any) {
uni.showToast({ title: error?.message || '加载失败', icon: 'none' });
} finally {
loading.value = false;
}
}
function reload() {
load(true);
}
function openDetail(id: number) {
uni.navigateTo({ url: '/packageApproval/pages/detail?id=' + id });
}
function createDraft() {
uni.navigateTo({
url: '/packageApproval/pages/form-completion?mode=create'
});
}
function formatTime(value?: string) {
if (!value) {
return '—';
}
return value.replace('T', ' ').substring(0, 19);
}
</script>
<style lang="scss">
.approval-page {
min-height: 100vh;
background-color: #f5f6f8;
padding-bottom: 140rpx;
}
.tabs {
display: flex;
background: #ffffff;
padding: 12rpx 24rpx;
position: sticky;
top: 0;
z-index: 10;
}
.tab {
flex: 1;
text-align: center;
padding: 16rpx 0;
font-size: 28rpx;
color: #606266;
border-bottom: 4rpx solid transparent;
}
.tab.active {
color: #d81e06;
font-weight: 600;
border-bottom-color: #d81e06;
}
.toolbar {
display: flex;
align-items: center;
gap: 16rpx;
padding: 16rpx 24rpx;
background: #ffffff;
}
.filter {
padding: 12rpx 20rpx;
font-size: 26rpx;
color: #303133;
background: #f2f3f5;
border-radius: 12rpx;
white-space: nowrap;
}
.search {
flex: 1;
height: 64rpx;
padding: 0 20rpx;
font-size: 26rpx;
background: #f2f3f5;
border-radius: 12rpx;
}
.list {
padding: 8rpx 0;
}
.item-card {
margin: 12rpx 16rpx;
}
.item-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
}
.item-title {
flex: 1;
font-size: 30rpx;
font-weight: 600;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status {
font-size: 24rpx;
padding: 4rpx 14rpx;
border-radius: 999rpx;
background: #f2f3f5;
color: #606266;
}
.status-DRAFT {
background: #f4f4f5;
color: #909399;
}
.status-PENDING {
background: #fff7e6;
color: #d46b08;
}
.status-REJECTED {
background: #fff1f0;
color: #cf1322;
}
.status-WITHDRAWN {
background: #f4f4f5;
color: #909399;
}
.status-APPROVED {
background: #f6ffed;
color: #389e0d;
}
.status-ARCHIVED {
background: #e6f4ff;
color: #0958d9;
}
.item-row {
display: flex;
align-items: center;
margin-top: 8rpx;
font-size: 26rpx;
}
.label {
width: 140rpx;
color: #909399;
}
.value {
flex: 1;
color: #303133;
word-break: break-all;
}
.placeholder {
padding: 160rpx 40rpx;
text-align: center;
}
.placeholder-title {
display: block;
font-size: 30rpx;
color: #606266;
}
.placeholder-desc {
display: block;
margin-top: 16rpx;
font-size: 26rpx;
color: #909399;
}
.empty,
.loading,
.finished {
padding: 40rpx;
text-align: center;
font-size: 26rpx;
color: #909399;
}
.fab {
position: fixed;
right: 40rpx;
bottom: 80rpx;
width: 100rpx;
height: 100rpx;
border-radius: 50%;
background: #d81e06;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 20rpx rgba(216, 30, 6, 0.35);
}
.fab-text {
color: #ffffff;
font-size: 56rpx;
line-height: 1;
}
</style>
+26
View File
@@ -33,6 +33,32 @@
}
],
"subPackages": [
{
"root": "packageApproval",
"pages": [
{
"path": "pages/index",
"style": {
"navigationBarTitleText": "审批中心",
"enablePullDownRefresh": true
}
},
{
"path": "pages/detail",
"style": {
"navigationBarTitleText": "单据详情",
"enablePullDownRefresh": false
}
},
{
"path": "pages/form-completion",
"style": {
"navigationBarTitleText": "业务完成确认单",
"enablePullDownRefresh": false
}
}
]
},
{
"root": "package",
"pages": [
+119 -2
View File
@@ -4,6 +4,29 @@
<view class="header" :style="'height: ' + statusBarHeight + 'px'" @click="phoneLogin"></view>
<!-- 用户信息 -->
<ws-user-card :data="userInfo" :users="users" :isLogin="isLogin" @openLogin="openLogin" @done="reload"/>
<uni-card
v-if="hasApprovalPermission"
class="approval-card"
:border="false"
:padding="'16px'"
@click="openApprovalCenter"
>
<view class="approval-entry">
<view class="approval-entry-left">
<view class="approval-icon">审</view>
<view class="approval-entry-text">
<text class="approval-title">审批中心</text>
<text class="approval-desc">待办 / 我发起的 / 已办</text>
</view>
</view>
<view class="approval-entry-right">
<view v-if="approvalTodoCount > 0" class="approval-dot">
{{ approvalTodoCount > 99 ? '99+' : approvalTodoCount }}
</view>
<text class="approval-arrow">›</text>
</view>
</view>
</uni-card>
<block v-if="hideTools">
<uni-card :border="false" :padding="'18px 8px'">
<uv-skeleton rows="2" :loading="hideTools" :title="false"></uv-skeleton>
@@ -62,8 +85,10 @@ import {useTenantStore} from '@/store/modules/tenant';
import {authUser, loginByPhoneReq} from "@/api/user";
import {getThinkOrderByCode, thinkOrderCheckCode} from "@/api/think/thinkOrder";
import {detectFace} from "@/api/think/thinkGate";
import {setHasFace} from "@/api/think/thinkUsers";
import {uploadFaceByPath} from "@/api/system/file";
import {setHasFace} from '@/api/think/thinkUsers';
import {uploadFaceByPath} from '@/api/system/file';
import {getTodoCount} from '@/api/approval/task';
import {hasPermission} from '@/utils/permission';
const tenantStore = useTenantStore();
const mpStore = useMpStore();
@@ -88,6 +113,9 @@ export default {
hasMerchantClerkRole: false,
// 是否拥有超级管理员权限
hasSuperAdminRole: false,
// 审批中心入口与待办红点
hasApprovalPermission: false,
approvalTodoCount: 0,
loginCount: 0
};
},
@@ -218,6 +246,9 @@ export default {
onLoad() {
this.reload();
},
onShow() {
this.loadApprovalEntry();
},
methods: {
async reload(e) {
const app = this;
@@ -326,8 +357,26 @@ export default {
app.isLogin = false;
app.userInfo = {};
}
await app.loadApprovalEntry();
app.hideTools = false;
},
async loadApprovalEntry() {
const allowed = hasPermission('approval:instance:list');
this.hasApprovalPermission = !!allowed;
if (!allowed) {
this.approvalTodoCount = 0;
return;
}
try {
const count = await getTodoCount();
this.approvalTodoCount = Number(count) || 0;
} catch (e) {
this.approvalTodoCount = 0;
}
},
openApprovalCenter() {
uni.navigateTo({url: '/packageApproval/pages/index'});
},
onLayout(data = {}) {
this.mpMenus = data.mpMenus || {};
this.hideTools = false;
@@ -546,6 +595,74 @@ page {
margin-bottom: 24rpx;
}
.approval-card {
margin: 12rpx 16rpx;
}
.approval-entry {
display: flex;
align-items: center;
justify-content: space-between;
}
.approval-entry-left {
display: flex;
align-items: center;
}
.approval-icon {
width: 72rpx;
height: 72rpx;
margin-right: 20rpx;
border-radius: 20rpx;
background: linear-gradient(135deg, #d81e06 0%, #ff7a45 100%);
color: #ffffff;
font-size: 30rpx;
display: flex;
align-items: center;
justify-content: center;
}
.approval-entry-text {
display: flex;
flex-direction: column;
}
.approval-title {
font-size: 30rpx;
font-weight: 600;
color: #303133;
}
.approval-desc {
margin-top: 6rpx;
font-size: 24rpx;
color: #909399;
}
.approval-entry-right {
display: flex;
align-items: center;
}
.approval-dot {
min-width: 36rpx;
height: 36rpx;
padding: 0 10rpx;
margin-right: 10rpx;
border-radius: 999rpx;
background: #ff4d4f;
color: #ffffff;
font-size: 22rpx;
line-height: 36rpx;
text-align: center;
}
.approval-arrow {
font-size: 40rpx;
color: #c0c4cc;
}
.section-title {
padding: 0 24rpx 16rpx;
font-size: 30rpx;