Files
gxtyzx-uniapp/src/packageApproval/pages/form-vehicle.vue
T
weicw 20d4128885 revert: 员工端附件上传回退为文件选择
撤销 7d1e482(改为拍照/相册选图),恢复 H5 chooseFile / 小程序
chooseMessageFile / App chooseImage 三段分支与「上传附件」文案。
2026-11-01 17:13:53 +08:00

752 lines
19 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>
<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="field tight">
<text class="field-label">单位名称<text class="required">*</text></text>
<input
v-model="row.unitName"
class="field-input"
type="text"
maxlength="100"
placeholder="请输入单位名称"
/>
</view>
<view class="line-row">
<view class="line-col">
<text class="field-label">人员信息<text class="required">*</text></text>
<input
v-model="row.personInfo"
class="field-input"
type="text"
maxlength="50"
placeholder="姓名/工号"
/>
</view>
<view class="line-col">
<text class="field-label">联系电话<text class="required">*</text></text>
<input
v-model="row.contactPhone"
class="field-input"
type="number"
maxlength="20"
placeholder="手机号"
/>
</view>
</view>
<view class="line-row">
<view class="line-col">
<text class="field-label">车牌号<text class="required">*</text></text>
<input
v-model="row.plateNo"
class="field-input"
type="text"
maxlength="20"
placeholder="如 桂A12345"
/>
</view>
<view class="line-col">
<text class="field-label">是否新能源<text class="required">*</text></text>
<view class="chips">
<view
class="chip"
:class="{ active: row.newEnergy === true }"
@click="row.newEnergy = true"
>
是
</view>
<view
class="chip"
:class="{ active: row.newEnergy === false }"
@click="row.newEnergy = false"
>
否
</view>
</view>
</view>
</view>
<view class="field tight">
<text class="field-label">车辆归属<text class="required">*</text></text>
<picker
:range="ownerTypeLabels"
:value="ownerTypeIndex(row)"
@change="onOwnerTypeChange($event, row)"
>
<view class="field-input picker">
{{ ownerTypeLabel(row.vehicleOwnerType) || '请选择车辆归属' }}
</view>
</picker>
</view>
<view class="line-row">
<view class="line-col">
<text class="field-label">有效期起<text class="required">*</text></text>
<uni-datetime-picker
v-model="row.validStartDate"
type="date"
placeholder="开始日期"
/>
</view>
<view class="line-col">
<text class="field-label">有效期止<text class="required">*</text></text>
<uni-datetime-picker
v-model="row.validEndDate"
type="date"
placeholder="结束日期"
/>
</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>
</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, ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
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 = 'vehicle';
const MAX_LINES = 100;
/** 车辆归属 8 类固定编码(ADR 0010),存编码不存中文 */
const OWNER_TYPES = [
{ value: 'GROUP_EMPLOYEE', label: '威宁集团公司员工车辆' },
{ value: 'CULTURE_TOURISM_EMPLOYEE', label: '南宁文旅集团公司员工车辆' },
{ value: 'GROUP_SUBSIDIARY_EMPLOYEE', label: '威宁集团下属公司员工车辆' },
{ value: 'WENNING_CULTURE_SPORTS_EMPLOYEE', label: '威宁文体公司员工车辆' },
{ value: 'COOPERATING_CLUB_EMPLOYEE', label: '合作俱乐部员工车辆' },
{ value: 'TENANT_EMPLOYEE', label: '广西体育中心租户员工车辆' },
{ value: 'LARGE_EVENT_ORGANIZER', label: '大型活动主办方车辆' },
{ value: 'OTHER', label: '其他车辆' }
];
interface VehicleLine {
key: number;
unitName: string;
personInfo: string;
plateNo: string;
newEnergy: boolean | null;
contactPhone: string;
vehicleOwnerType: string;
validStartDate: string;
validEndDate: 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 lines = ref<VehicleLine[]>([]);
const attachments = ref<ApprovalAttachment[]>([]);
const category = ref('OTHER');
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 ownerTypeLabels = OWNER_TYPES.map(item => item.label);
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>): VehicleLine {
return {
key: lineKey++,
unitName: textOf(source?.unitName),
personInfo: textOf(source?.personInfo),
plateNo: textOf(source?.plateNo),
newEnergy:
source?.newEnergy === undefined || source?.newEnergy === null
? null
: source.newEnergy === true || source.newEnergy === 1 || source.newEnergy === 'true',
contactPhone: textOf(source?.contactPhone),
vehicleOwnerType: textOf(source?.vehicleOwnerType),
validStartDate: textOf(source?.validStartDate).substring(0, 10),
validEndDate: textOf(source?.validEndDate).substring(0, 10),
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);
}
function ownerTypeIndex(row: VehicleLine): number {
const index = OWNER_TYPES.findIndex(item => item.value === row.vehicleOwnerType);
return index >= 0 ? index : 0;
}
function ownerTypeLabel(value: string): string {
const option = OWNER_TYPES.find(item => item.value === value);
return option ? option.label : '';
}
function onOwnerTypeChange(event: { detail: { value: string | number } }, row: VehicleLine) {
const option = OWNER_TYPES[Number(event.detail.value)];
row.vehicleOwnerType = option ? option.value : '';
}
async function loadDetail() {
try {
const detail = await getInstance(instanceId.value);
if (!detail.permissions?.canEdit) {
showError('当前状态不可修改', null);
return;
}
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);
}
}
/** 车牌号:汉字/字母 + 字母 + 5~6 位字母数字(含新能源 6 位) */
function isValidPlate(value: string): boolean {
return /^[\u4e00-\u9fa5][A-Z][A-Z0-9]{5,6}$/.test(value.trim().toUpperCase());
}
function validate(): string | null {
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.unitName.trim()) {
return '第' + lineNo + '行单位名称不能为空';
}
if (row.unitName.trim().length > 100) {
return '第' + lineNo + '行单位名称不能超过100字';
}
if (!row.personInfo.trim()) {
return '第' + lineNo + '行人员信息不能为空';
}
if (row.personInfo.trim().length > 50) {
return '第' + lineNo + '行人员信息不能超过50字';
}
if (!row.contactPhone.trim()) {
return '第' + lineNo + '行联系电话不能为空';
}
if (row.contactPhone.trim().length > 20) {
return '第' + lineNo + '行联系电话不能超过20字';
}
if (!row.plateNo.trim()) {
return '第' + lineNo + '行车牌号不能为空';
}
if (!isValidPlate(row.plateNo)) {
return '第' + lineNo + '行车牌号格式不正确';
}
if (row.newEnergy === null) {
return '第' + lineNo + '行请选择是否新能源';
}
if (!row.vehicleOwnerType) {
return '第' + lineNo + '行请选择车辆归属';
}
if (!row.validStartDate) {
return '第' + lineNo + '行请选择有效期起';
}
if (!row.validEndDate) {
return '第' + lineNo + '行请选择有效期止';
}
if (row.validEndDate < row.validStartDate) {
return '第' + lineNo + '行有效期止不能早于有效期起';
}
if (row.remark.trim().length > 200) {
return '第' + lineNo + '行备注不能超过200字';
}
}
return null;
}
function buildLines(): Array<Record<string, any>> {
return lines.value.map(row => ({
unitName: row.unitName.trim(),
personInfo: row.personInfo.trim(),
plateNo: row.plateNo.trim().toUpperCase(),
newEnergy: row.newEnergy === true,
contactPhone: row.contactPhone.trim(),
vehicleOwnerType: row.vehicleOwnerType,
validStartDate: row.validStartDate,
validEndDate: row.validEndDate,
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,
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;
}
.field-input.picker {
color: #303133;
}
.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;
}
.chips {
display: flex;
gap: 16rpx;
}
.chip {
padding: 8rpx 24rpx;
font-size: 24rpx;
color: #606266;
background: #eef0f3;
border-radius: 999rpx;
line-height: 40rpx;
}
.chip.active {
color: #ffffff;
background: #d81e06;
}
.add-button {
margin-top: 24rpx;
font-size: 26rpx;
background: #f7f8fa;
color: #303133;
border: 1rpx dashed #dcdfe6;
}
.category-row {
display: flex;
align-items: center;
margin-bottom: 16rpx;
}
.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>