Files
gxtyzx-uniapp/src/packageApproval/pages/form-facility.vue
T

750 lines
18 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="基本信息">
<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>
<uni-datetime-picker
v-model="form.effectiveDate"
type="date"
placeholder="选择生效日期"
/>
</view>
<text class="hint">
允许未来生效;不允许早于归档日的追溯修改,归档时若生效日期早于当天将被拒绝。
</text>
</uni-card>
<uni-card :border="false" :padding="'16px'" title="价格明细">
<view v-for="(row, index) in lines" :key="row.key" class="line-card">
<view class="line-head">
<text class="line-title">第 {{ index + 1 }} 行</text>
<text class="delete" @click="removeLine(index)">删除</text>
</view>
<view class="line-row">
<view class="line-col">
<text class="field-label">名称<text class="required">*</text></text>
<input
v-model="row.itemName"
class="field-input"
type="text"
maxlength="200"
placeholder="请输入名称"
/>
</view>
<view class="line-col">
<text class="field-label">位置</text>
<input
v-model="row.location"
class="field-input"
type="text"
maxlength="200"
placeholder="选填"
/>
</view>
</view>
<view class="line-row">
<view class="line-col">
<text class="field-label">单位<text class="required">*</text></text>
<input
v-model="row.unit"
class="field-input"
type="text"
maxlength="20"
placeholder="如 项"
/>
</view>
<view class="line-col">
<text class="field-label">数量<text class="required">*</text></text>
<input
v-model="row.quantity"
class="field-input"
type="digit"
placeholder="最多3位小数"
/>
</view>
</view>
<view class="line-row">
<view class="line-col">
<text class="field-label">单价(元)<text class="required">*</text></text>
<input
v-model="row.unitPrice"
class="field-input"
type="digit"
placeholder="最多2位小数"
/>
</view>
<view class="line-col">
<text class="field-label">金额(元)</text>
<text class="amount-value">{{ rowAmount(row) }}</text>
</view>
</view>
<view class="field tight">
<text class="field-label">备注</text>
<input
v-model="row.remark"
class="field-input"
type="text"
maxlength="200"
placeholder="选填"
/>
</view>
</view>
<view v-if="lines.length === 0" class="empty">请添加至少一行价格条目</view>
<button class="add-button" @click="addLine">添加明细行({{ lines.length }}/100)</button>
<view class="total-row">
<text class="total-label">合计金额</text>
<text class="total-value">¥{{ totalAmount }}</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>
<text class="hint">提交时需至少上传 1 张签字件(经办人签字原件扫描件)。</text>
<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 Decimal from 'decimal.js';
import { useUserStore } from '@/store/modules/user';
import { getInstance, saveDocument, 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 FLOW_CODE = 'facility';
const MAX_LINES = 100;
interface FacilityLine {
key: number;
itemName: string;
location: string;
unit: string;
quantity: string;
unitPrice: string;
remark: string;
}
const userStore = useUserStore();
const categories = [
{ value: 'SIGNED', label: '签字件' },
{ value: 'VOUCHER', label: '凭证' },
{ value: 'OTHER', label: '其他' }
];
const mode = ref<'create' | 'edit'>('create');
const instanceId = ref(0);
const form = reactive({ effectiveDate: '' });
const lines = ref<FacilityLine[]>([]);
const attachments = ref<ApprovalAttachment[]>([]);
const category = ref('SIGNED');
const saving = ref(false);
const submitting = ref(false);
const uploading = ref(false);
let lineKey = 1;
const reporterName = computed(
() => userStore.info?.realName || userStore.info?.nickname || ''
);
const reportOrgName = computed(() => userStore.info?.organizationName || '');
const totalAmount = computed(() => {
let total = new Decimal(0);
for (const row of lines.value) {
const amount = rowAmount(row);
if (amount) {
total = total.add(new Decimal(amount));
}
}
return total.toFixed(2);
});
onLoad(options => {
mode.value = options?.mode === 'edit' ? 'edit' : 'create';
instanceId.value = Number(options?.id) || 0;
if (instanceId.value) {
loadDetail();
} else {
addLine();
}
});
function textOf(value: any): string {
return value === undefined || value === null ? '' : String(value);
}
function createLine(source?: Record<string, any>): FacilityLine {
return {
key: lineKey++,
itemName: textOf(source?.itemName),
location: textOf(source?.location),
unit: textOf(source?.unit),
quantity: textOf(source?.quantity),
unitPrice: textOf(source?.unitPrice),
remark: textOf(source?.remark)
};
}
function addLine() {
if (lines.value.length >= MAX_LINES) {
showError('明细不能超过100行', null);
return;
}
lines.value.push(createLine());
}
function removeLine(index: number) {
lines.value.splice(index, 1);
}
/** 行金额:数量 × 单价,保留两位小数(HALF_UP),仅用于展示与提交 */
function rowAmount(row: FacilityLine): string {
if (!row.quantity || !row.unitPrice) {
return '';
}
try {
return new Decimal(row.quantity)
.mul(new Decimal(row.unitPrice))
.toDecimalPlaces(2, Decimal.ROUND_HALF_UP)
.toFixed(2);
} catch (error) {
return '';
}
}
function isValidDecimal(value: string, scale: number): boolean {
if (!value) {
return false;
}
const pattern = new RegExp('^\\d+(\\.\\d{1,' + scale + '})?$');
return pattern.test(value.trim());
}
async function loadDetail() {
try {
const detail = await getInstance(instanceId.value);
if (!detail.permissions?.canEdit) {
showError('当前状态不可修改', null);
return;
}
const fields = detail.document?.fields || [];
form.effectiveDate = fieldValue(fields, 'effectiveDate').substring(0, 10);
const rows = detail.document?.lines || [];
lines.value = rows.length > 0 ? rows.map(row => createLine(row)) : [createLine()];
attachments.value = detail.attachments || [];
} 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) : '';
}
/** 本地日期 yyyy-MM-dd(不用 toISOString,避免时区偏移一天) */
function today(): string {
const now = new Date();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
return now.getFullYear() + '-' + month + '-' + day;
}
function validate(): string | null {
if (!form.effectiveDate) {
return '请选择生效日期';
}
if (form.effectiveDate < today()) {
return '生效日期不能早于今天,追溯修改不被允许';
}
if (lines.value.length === 0) {
return '请至少填写一行明细';
}
for (let index = 0; index < lines.value.length; index++) {
const row = lines.value[index];
const lineNo = index + 1;
if (!row.itemName.trim()) {
return '第' + lineNo + '行名称不能为空';
}
if (row.itemName.trim().length > 200) {
return '第' + lineNo + '行名称不能超过200字';
}
if (row.location.trim().length > 200) {
return '第' + lineNo + '行位置不能超过200字';
}
if (!row.unit.trim()) {
return '第' + lineNo + '行单位不能为空';
}
if (row.unit.trim().length > 20) {
return '第' + lineNo + '行单位不能超过20字';
}
if (!isValidDecimal(row.quantity, 3)) {
return '第' + lineNo + '行数量必须为数字,最多3位小数';
}
if (!isValidDecimal(row.unitPrice, 2)) {
return '第' + lineNo + '行单价必须为数字,最多2位小数';
}
if (row.remark.trim().length > 200) {
return '第' + lineNo + '行备注不能超过200字';
}
}
return null;
}
function buildLines(): Array<Record<string, any>> {
return lines.value.map(row => ({
itemName: row.itemName.trim(),
location: row.location.trim() || undefined,
unit: row.unit.trim(),
quantity: row.quantity.trim(),
unitPrice: row.unitPrice.trim(),
amount: rowAmount(row),
remark: row.remark.trim() || undefined
}));
}
async function ensureDraft(): Promise<number> {
const message = validate();
if (message) {
showError(message, null);
return 0;
}
saving.value = true;
try {
const id = await saveDocument(FLOW_CODE, {
id: instanceId.value || undefined,
effectiveDate: form.effectiveDate,
lines: buildLines()
});
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.tight {
margin-top: 16rpx;
}
.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;
}
.line-card {
margin-top: 20rpx;
padding: 16rpx;
background: #fafbfc;
border: 1rpx solid #ebeef5;
border-radius: 12rpx;
}
.line-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.line-title {
font-size: 26rpx;
font-weight: 600;
color: #303133;
}
.line-row {
display: flex;
gap: 20rpx;
margin-top: 16rpx;
}
.line-col {
flex: 1;
min-width: 0;
}
.amount-value {
display: block;
height: 72rpx;
padding: 0 20rpx;
font-size: 28rpx;
line-height: 72rpx;
color: #d81e06;
background: #fff7f6;
border-radius: 12rpx;
}
.add-button {
margin-top: 24rpx;
font-size: 26rpx;
background: #f7f8fa;
color: #303133;
border: 1rpx dashed #dcdfe6;
}
.total-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 24rpx;
padding-top: 20rpx;
border-top: 1rpx solid #f0f0f0;
}
.total-label {
font-size: 28rpx;
color: #303133;
}
.total-value {
font-size: 32rpx;
font-weight: 600;
color: #d81e06;
}
.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>