feat: 员工端二期 7 张单据表单页与通用详情渲染(#46)

This commit is contained in:
weicw
2026-10-31 07:21:20 +08:00
parent 03e4dc8f9b
commit 3d4f1949be
13 changed files with 5186 additions and 37 deletions
+751
View File
@@ -0,0 +1,751 @@
<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>