Files
gxtyzx-uniapp/src/packageApproval/pages/form-coordination.vue
T
weicw 2f8bb7e820 fix(approval): 上传附件先建空草稿,不再要求先填完表单
八个审批表单页的 uploadFiles 由 ensureDraft(全量校验)改为 ensureInstance:
未建实例时调 createEmptyDraft 只拿实例 id,附件可先传;保存/提交仍走原校验。

后端新增 POST /approval/instance/{flowCode}/draft,空草稿不能提交。
2026-11-05 11:44:02 +08:00

659 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="form-page">
<uni-card :border="false" :padding="'16px'" :title="pageTitle + ' — 基本信息'">
<view class="auto-row">
<text class="label">单据类型</text>
<text class="value">{{ pageTitle }}</text>
</view>
<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>
<picker
:range="organizationLabels"
:value="orgIndex"
@change="onOrgChange"
>
<view class="field-input picker">
{{ selectedOrgName || '请选择受理部门' }}
</view>
</picker>
</view>
<view class="field">
<text class="field-label">标题<text class="required">*</text></text>
<input
v-model="form.title"
class="field-input"
type="text"
maxlength="100"
placeholder="请输入标题"
/>
<text class="counter">{{ form.title.length }}/100</text>
</view>
<view class="field">
<text class="field-label">内容<text class="required">*</text></text>
<textarea
v-model="form.content"
class="field-textarea"
maxlength="2000"
placeholder="请输入协调内容"
/>
<text class="counter">{{ form.content.length }}/2000</text>
</view>
<view class="field">
<text class="field-label">备注</text>
<textarea
v-model="form.remark"
class="field-textarea"
maxlength="500"
placeholder="选填"
/>
<text class="counter">{{ form.remark.length }}/500</text>
</view>
</uni-card>
<uni-card :border="false" :padding="'16px'" title="附件">
<view class="category-row">
<text class="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 {
createEmptyDraft,
getInstance,
saveCoordination,
saveDocument,
submitInstance
} from '@/api/approval/instance';
import {
deleteAttachment,
uploadAttachment
} from '@/api/approval/attachment';
import { getOrganizations } from '@/api/approval/directory';
import type {
ApprovalAttachment,
ApprovalOrganization
} from '@/api/approval/model';
import { showError, showSuccess } from '@/utils/app';
const userStore = useUserStore();
const categories = [
{ value: 'SIGNED', label: '签字件' },
{ value: 'VOUCHER', label: '凭证' },
{ value: 'OTHER', label: '其他' }
];
const mode = ref<'create' | 'edit'>('create');
const instanceId = ref(0);
/** 流程编码:内部 coordination_internal / 外部 coordination_external(ADR 0007) */
const flowCode = ref('coordination_internal');
const organizations = ref<ApprovalOrganization[]>([]);
const orgIndex = ref(0);
const form = reactive({
acceptOrgId: 0,
title: '',
content: '',
remark: ''
});
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 || '');
const pageTitle = computed(() =>
flowCode.value === 'coordination_external' ? '外部协调单' : '内部协调单'
);
const organizationLabels = computed(() =>
organizations.value.map(item => item.organizationName)
);
const selectedOrgName = computed(
() => organizations.value[orgIndex.value]?.organizationName || ''
);
onLoad(options => {
mode.value = options?.mode === 'edit' ? 'edit' : 'create';
instanceId.value = Number(options?.id) || 0;
flowCode.value =
options?.flowCode === 'coordination_external'
? 'coordination_external'
: 'coordination_internal';
uni.setNavigationBarTitle({ title: pageTitle.value });
loadOrganizations();
if (instanceId.value) {
loadDetail();
}
});
async function loadOrganizations() {
try {
organizations.value = await getOrganizations();
syncOrgIndex();
} catch (error: any) {
showError(error?.message || '受理部门加载失败', null);
}
}
function syncOrgIndex() {
if (!form.acceptOrgId) {
orgIndex.value = 0;
return;
}
const index = organizations.value.findIndex(
item => item.organizationId === form.acceptOrgId
);
orgIndex.value = index >= 0 ? index : 0;
}
function onOrgChange(event: { detail: { value: string | number } }) {
orgIndex.value = Number(event.detail.value);
const organization = organizations.value[orgIndex.value];
form.acceptOrgId = organization ? organization.organizationId : 0;
}
async function loadDetail() {
try {
const detail = await getInstance(instanceId.value);
if (detail.docType !== 'coordination') {
showError('单据类型不匹配', null);
return;
}
if (!detail.permissions?.canEdit) {
showError('当前状态不可修改', null);
return;
}
// 编辑草稿时以实例上的流程为准,避免参数缺失导致存成另一个流程
if (
detail.flowCode === 'coordination_external' ||
detail.flowCode === 'coordination_internal'
) {
flowCode.value = detail.flowCode;
uni.setNavigationBarTitle({ title: pageTitle.value });
}
form.acceptOrgId = detail.acceptOrgId || 0;
const fields = detail.document?.fields || [];
form.title = detail.title || fieldValue(fields, 'title');
form.content = detail.content || fieldValue(fields, 'content');
form.remark = detail.remark || fieldValue(fields, 'remark');
attachments.value = detail.attachments || [];
syncOrgIndex();
} catch (error: any) {
showError(error?.message || '加载失败', null);
}
}
function fieldValue(fields: Array<{ key: string; value?: string | null }>, key: string) {
const field = fields.find(item => item.key === key);
return field?.value ? String(field.value) : '';
}
function validate(): string | null {
if (!form.acceptOrgId) {
return '请选择受理部门';
}
if (!form.title.trim()) {
return '请输入标题';
}
if (form.title.trim().length > 100) {
return '标题不能超过100字';
}
if (!form.content.trim()) {
return '请输入内容';
}
if (form.content.trim().length > 2000) {
return '内容不能超过2000字';
}
if (form.remark.trim().length > 500) {
return '备注不能超过500字';
}
return null;
}
async function ensureDraft(): Promise<number> {
const message = validate();
if (message) {
showError(message, null);
return 0;
}
saving.value = true;
try {
// 内部流程仍走一期端点,外部流程走通用草稿端点(同一张表、同一份表单)
const payload = {
id: instanceId.value || undefined,
acceptOrgId: form.acceptOrgId,
title: form.title.trim(),
content: form.content.trim(),
remark: form.remark.trim() || undefined
};
const id =
flowCode.value === 'coordination_external'
? await saveDocument(flowCode.value, payload)
: await saveCoordination(payload);
instanceId.value = id;
mode.value = 'edit';
return id;
} catch (error: any) {
showError(error?.message || '保存失败', null);
return 0;
} finally {
saving.value = false;
}
}
/**
* 上传附件只需要实例 id:不校验表单,未建实例时先建空草稿
*/
async function ensureInstance(): Promise<number> {
if (instanceId.value) {
return instanceId.value;
}
try {
const id = await createEmptyDraft(flowCode.value);
instanceId.value = id;
mode.value = 'edit';
return id;
} catch (error: any) {
showError(error?.message || '创建草稿失败', null);
return 0;
}
}
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 chooseFail(res: any) {
let detail = '';
try {
detail =
res && typeof res.errMsg === 'string' ? res.errMsg : JSON.stringify(res);
} catch (error) {
detail = String(res);
}
if (!detail) {
showError('选择文件失败:未知错误', null);
return;
}
// 用户自己取消选择不算失败,不弹提示
if (/cancel/i.test(detail)) {
return;
}
// 微信隐私协议未声明「收集你选中的文件」时 chooseMessageFile 会被拦截
if (/privacy agreement/i.test(detail)) {
showError(
'微信小程序隐私协议未声明「收集你选中的文件」,需在微信公众平台「账号设置-服务内容声明-用户隐私保护指引」补充并审核通过后重试。原始信息:' +
detail,
null
);
return;
}
showError('选择文件失败:' + detail, null);
}
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: chooseFail
});
// #endif
// #ifdef MP-WEIXIN
(uni as any).chooseMessageFile({
count: remaining,
type: 'file',
success: (result: any) => uploadFiles(result.tempFiles || []),
fail: chooseFail
});
// #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: chooseFail
});
// #endif
}
async function uploadFiles(files: Array<{ path: string; name?: string; size?: number }>) {
if (!files.length) {
return;
}
const id = await ensureInstance();
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;
line-height: 72rpx;
background: #f7f8fa;
border-radius: 12rpx;
}
.field-input.picker {
color: #303133;
}
.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>