533 lines
12 KiB
Vue
533 lines
12 KiB
Vue
<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>
|