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
+181
View File
@@ -67,6 +67,8 @@ export interface ApprovalPermissions {
canArchive: boolean;
canDispatch: boolean;
canAssign: boolean;
/** 当前处理人可回填的字段;非节点处理人为空 */
writableFields?: ApprovalWritableField[];
}
/**
@@ -115,6 +117,10 @@ export interface ApprovalInstance extends ApprovalItem {
confirms: ApprovalConfirm[];
myTask?: ApprovalMyTask | null;
permissions: ApprovalPermissions;
/** 单据业务内容:业务字段、明细、会签进度与改值历史 */
document?: ApprovalDocument;
/** 字段改值历史(最新在后) */
fieldChanges?: ApprovalFieldChange[];
}
/**
@@ -145,6 +151,8 @@ export interface ApprovalCoordinationForm {
export interface ApprovalMineParam extends PageParam {
status?: string;
docType?: string;
/** 流程编码,如 settlement(按流程类型筛选) */
flowCode?: string;
}
/**
@@ -153,6 +161,8 @@ export interface ApprovalMineParam extends PageParam {
export interface ApprovalTaskListParam extends PageParam {
status?: string;
docType?: string;
/** 流程编码,如 settlement(按流程类型筛选) */
flowCode?: string;
}
/**
@@ -185,3 +195,174 @@ export interface ApprovalUser {
organizationId?: number;
organizationName?: string;
}
/**
* 详情中的业务字段(后端已格式化好的展示值)
*/
export interface ApprovalField {
key: string;
label: string;
/** 展示值;空值为 null */
value?: string | null;
}
/**
* 明细列元数据,驱动详情表格与服务端校验
*/
export interface ApprovalLineColumn {
key: string;
label: string;
/** TEXT / DECIMAL / INT / DATE / BOOL / ENUM */
type: string;
required: boolean;
scale?: number | null;
maxLength?: number | null;
/** 金额列:由服务端按数量 × 单价重算 */
money?: boolean;
options?: Array<{ value: string; label: string }> | null;
}
/**
* 明细汇总:行数与合计金额(金额类单据才有合计)
*/
export interface ApprovalLineSummary {
lineCount: number;
amountTotal?: number | null;
}
/**
* 会签组成员状态
*/
export interface ApprovalGroupMember {
userId?: number;
userName?: string;
status: string;
statusName: string;
}
/**
* 会签进度:按组展示「已签 N/M」
*/
export interface ApprovalGroupProgress {
nodeKey: string;
nodeName: string;
groupKey: string;
groupName: string;
groupSeq?: number;
signed: number;
total: number;
members?: ApprovalGroupMember[];
}
/**
* 字段改值历史(回填 / 撤回清空等留痕)
*/
export interface ApprovalFieldChange {
id?: number;
nodeKey?: string;
nodeName?: string;
fieldKey: string;
fieldLabel?: string;
oldValue?: string | null;
newValue?: string | null;
operatorName?: string;
round?: number;
createTime?: string;
}
/**
* 单据业务内容:新页面统一读这里,一期扁平字段仅作兼容
*/
export interface ApprovalDocument {
fields: ApprovalField[];
lines: Array<Record<string, any>>;
lineColumns?: ApprovalLineColumn[] | null;
lineSummary?: ApprovalLineSummary | null;
groupProgress?: ApprovalGroupProgress[] | null;
fieldChanges?: ApprovalFieldChange[] | null;
}
/**
* 当前节点可回填字段
*/
export interface ApprovalWritableField {
field: string;
label: string;
required: boolean;
value?: string | null;
}
/**
* 明细类单据草稿参数(结算单 / 车辆单 / 体验券 / 设施价格表)
*/
export interface ApprovalLineDocumentForm {
id?: number;
lines: Array<Record<string, any>>;
}
/**
* 结算单草稿参数
*/
export interface ApprovalSettlementForm extends ApprovalLineDocumentForm {
settlementReason: string;
settlementPeriod?: string;
counterpartyName: string;
contractNo?: string;
}
/**
* 收据申请单草稿参数
*/
export interface ApprovalReceiptForm {
id?: number;
amount: number | string;
payerName: string;
reason: string;
receiptNo?: string;
applyDate?: string;
}
/**
* 车辆免收费登记表草稿参数
*/
export interface ApprovalVehicleForm extends ApprovalLineDocumentForm {}
/**
* 场地使用申请表草稿参数
*/
export interface ApprovalVenueForm {
id?: number;
applicantName: string;
/** ORG 单位 / PERSON 个人 */
applicantType: string;
/** yyyy-MM-dd HH:mm:ss */
useStartTime: string;
useEndTime: string;
siteId: number;
siteName: string;
purpose: string;
activityFee: number | string;
}
/**
* 体验券领用申请单草稿参数
*/
export interface ApprovalCouponForm extends ApprovalLineDocumentForm {
applyReason: string;
}
/**
* 设施价格表草稿参数
*/
export interface ApprovalFacilityForm extends ApprovalLineDocumentForm {
/** yyyy-MM-dd */
effectiveDate: string;
}
/**
* 流程筛选选项(标签取流程定义名称)
*/
export interface ApprovalFlowOption {
value: string;
label: string;
}
+20
View File
@@ -0,0 +1,20 @@
import request from '@/utils/request';
import type { ApiResult } from '@/api';
import type { BookingSite, BookingSiteParam } from './model';
import { MODULES_API_URL } from '@/config/setting';
/**
* 查询场馆场地列表(场地主数据,供场地使用申请选择)
*/
export async function listBookingSite(params?: BookingSiteParam) {
const res = await request.get<ApiResult<BookingSite[]>>(
MODULES_API_URL + '/booking/booking-site',
{
params
}
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
@@ -0,0 +1,51 @@
import type { PageParam } from '@/api';
/**
* 场馆场地(场地主数据)
*/
export interface BookingSite {
id?: number;
// 场地名称
name?: string;
// 封面图
thumb?: string;
// 每小时价格
price?: string;
// 营业时间
businessTime?: string;
// 场馆地址
address?: string;
// 场馆介绍
info?: string;
// 场馆电话
tel?: string;
// 排序
sortNumber?: number;
// 类型:1天,2小时
type?: string;
// 天数或小时
num?: string;
// 退款比率
proportion?: string;
// 退款规则组
moneyJson?: string;
// 场馆图片
files?: string;
// 创建时间
createTime?: number;
// 更新时间
updateTime?: number;
// 周末活动订场开关:1开,0关
weekendOpen?: number;
// 租户id
tenantId?: number;
}
/**
* 场地搜索条件
*/
export interface BookingSiteParam extends PageParam {
id?: number;
name?: string;
keywords?: string;
}
+481 -13
View File
@@ -3,7 +3,7 @@
<view v-if="detail" class="content">
<uni-card :border="false" :padding="'16px'">
<view class="head">
<text class="title">{{ detail.title || detail.name || '单据详情' }}</text>
<text class="title">{{ displayTitle }}</text>
<text class="status" :class="'status-' + detail.status">
{{ detail.statusName }}
</text>
@@ -49,7 +49,22 @@
</uni-card>
<uni-card :border="false" :padding="'16px'" title="单据信息">
<view v-if="detail.docType === 'completion'" class="rows">
<!-- 二期通用:业务字段由后端 document.fields 有序提供 -->
<view v-if="documentFields.length > 0" class="rows">
<view
v-for="field in documentFields"
:key="field.key"
class="row"
:class="{ 'row-block': isLongField(field) }"
>
<text class="label">{{ field.label }}</text>
<text class="value" :class="{ multiline: isLongField(field) }">
{{ field.value || '—' }}
</text>
</view>
</view>
<!-- 一期回退:老数据可能没有 document.fields -->
<view v-else-if="detail.docType === 'completion'" class="rows">
<view class="row">
<text class="label">名称</text>
<text class="value">{{ detail.name }}</text>
@@ -67,7 +82,7 @@
<text class="value multiline">{{ detail.serviceContent }}</text>
</view>
</view>
<view v-else class="rows">
<view v-else-if="detail.docType === 'coordination'" class="rows">
<view class="row">
<text class="label">受理部门</text>
<text class="value">{{ detail.acceptOrgName || '—' }}</text>
@@ -85,6 +100,75 @@
<text class="value multiline">{{ detail.remark || '—' }}</text>
</view>
</view>
<view v-else class="empty">暂无单据信息</view>
</uni-card>
<uni-card
v-if="lineColumns.length > 0"
:border="false"
:padding="'12px'"
title="明细"
>
<scroll-view class="table-scroll" scroll-x>
<view class="table">
<view class="table-row table-head">
<text class="table-cell cell-no">#</text>
<text
v-for="column in lineColumns"
:key="column.key"
class="table-cell"
>
{{ column.label }}
</text>
</view>
<view
v-for="(row, index) in lines"
:key="index"
class="table-row"
>
<text class="table-cell cell-no">{{ index + 1 }}</text>
<text
v-for="column in lineColumns"
:key="column.key"
class="table-cell"
>
{{ displayLineValue(row, column) }}
</text>
</view>
</view>
</scroll-view>
<view v-if="lines.length === 0" class="empty">暂无明细</view>
<view v-if="lineSummary" class="summary-row">
<text class="summary-label">共 {{ lineSummary.lineCount }} 行</text>
<text v-if="hasAmountTotal" class="summary-value">
合计 ¥{{ formatAmount(lineSummary.amountTotal) }}
</text>
</view>
</uni-card>
<uni-card
v-if="groupProgress.length > 0"
:border="false"
:padding="'16px'"
title="会签进度"
>
<view v-for="group in groupProgress" :key="group.nodeKey + '-' + group.groupKey" class="group">
<view class="group-head">
<text class="group-name">{{ group.groupName }}</text>
<text class="group-progress">已签 {{ group.signed }}/{{ group.total }}</text>
</view>
<text class="group-node">{{ group.nodeName }}</text>
<view class="group-members">
<text
v-for="member in group.members || []"
:key="member.userId"
class="member"
:class="'member-' + member.status"
>
{{ member.userName || '—' }} · {{ member.statusName }}
</text>
</view>
</view>
</uni-card>
<uni-card :border="false" :padding="'16px'" title="附件">
@@ -105,6 +189,27 @@
<view v-if="detail.attachments.length === 0" class="empty">暂无附件</view>
</uni-card>
<uni-card
v-if="fieldChanges.length > 0"
:border="false"
:padding="'16px'"
title="字段改值记录"
>
<view v-for="(item, index) in fieldChanges" :key="item.id || index" class="change">
<view class="change-head">
<text class="change-field">{{ item.fieldLabel || item.fieldKey }}</text>
<text class="change-operator">
{{ item.operatorName || '—' }}
<text v-if="item.round"> · 第 {{ item.round }} 轮</text>
</text>
</view>
<text class="change-value">
{{ item.oldValue || '(空)' }} → {{ item.newValue || '(空)' }}
</text>
<text class="change-time">{{ formatTime(item.createTime) }}</text>
</view>
</uni-card>
<uni-card :border="false" :padding="'16px'" title="处理记录">
<view v-for="item in detail.records" :key="item.id" class="timeline-item">
<view class="timeline-dot" :class="dotClass(item.action)"></view>
@@ -204,6 +309,23 @@
<view v-if="actionDialog" class="dialog-mask">
<view class="dialog">
<text class="dialog-title">{{ actionDialog === 'approve' ? '审批意见' : '驳回意见' }}</text>
<!-- 节点回填字段(ADR 0006):通过时必填校验,也可先「仅回填」再通过 -->
<view v-if="actionDialog === 'approve' && writableFields.length > 0" class="dialog-fields">
<view v-for="field in writableFields" :key="field.field" class="dialog-field">
<text class="dialog-field-label">
{{ field.label }}
<text v-if="field.required" class="required">*</text>
</text>
<input
v-model="fieldValues[field.field]"
class="dialog-field-input"
type="text"
:placeholder="'请输入' + field.label"
/>
</view>
</view>
<textarea
v-model="opinion"
class="dialog-input"
@@ -212,6 +334,14 @@
/>
<view class="dialog-actions">
<button class="dialog-button ghost" @click="closeDialog">取消</button>
<button
v-if="actionDialog === 'approve' && writableFields.length > 0"
class="dialog-button ghost"
:loading="submitting"
@click="confirmBackfill"
>
仅回填
</button>
<button class="dialog-button primary" :loading="submitting" @click="confirmAction">
确定
</button>
@@ -226,25 +356,85 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { computed, reactive, ref } from 'vue';
import { onLoad, onShow } from '@dcloudio/uni-app';
import { getInstance, submitInstance } from '@/api/approval/instance';
import {
approveTask,
archiveTask,
backfillTask,
confirmTask,
rejectTask,
withdrawInstance
} from '@/api/approval/task';
import type { ApprovalInstance } from '@/api/approval/model';
import type {
ApprovalField,
ApprovalFieldChange,
ApprovalGroupProgress,
ApprovalInstance,
ApprovalLineColumn,
ApprovalLineSummary,
ApprovalWritableField
} from '@/api/approval/model';
import { showError } from '@/utils/app';
/** 单据编辑页路由映射:flowCode 优先,缺失时回退 docType(一期数据没有 flowCode 语义) */
const EDIT_PAGES: Record<string, string> = {
completion: 'form-completion',
coordination_internal: 'form-coordination',
coordination_external: 'form-coordination',
settlement: 'form-settlement',
receipt: 'form-receipt',
vehicle: 'form-vehicle',
venue: 'form-venue',
coupon: 'form-coupon',
facility: 'form-facility'
};
const detail = ref<ApprovalInstance | null>(null);
const loading = ref(false);
const submitting = ref(false);
const instanceId = ref(0);
const actionDialog = ref<'' | 'approve' | 'reject'>('');
const opinion = ref('');
const writableFields = ref<ApprovalWritableField[]>([]);
const fieldValues = reactive<Record<string, string>>({});
const documentFields = computed<ApprovalField[]>(
() => detail.value?.document?.fields || []
);
const lineColumns = computed<ApprovalLineColumn[]>(
() => detail.value?.document?.lineColumns || []
);
const lines = computed<Array<Record<string, any>>>(
() => detail.value?.document?.lines || []
);
const lineSummary = computed<ApprovalLineSummary | null>(
() => detail.value?.document?.lineSummary || null
);
const groupProgress = computed<ApprovalGroupProgress[]>(
() => detail.value?.document?.groupProgress || []
);
const fieldChanges = computed<ApprovalFieldChange[]>(() => {
const fromDetail = detail.value?.fieldChanges || [];
if (fromDetail.length > 0) {
return fromDetail;
}
return detail.value?.document?.fieldChanges || [];
});
const hasAmountTotal = computed(
() =>
lineSummary.value?.amountTotal !== undefined &&
lineSummary.value?.amountTotal !== null
);
/** 标题可能为空(老数据),回退到名称/单号 */
const displayTitle = computed(
() =>
detail.value?.title ||
detail.value?.name ||
detail.value?.bizNo ||
'单据详情'
);
const hasActions = computed(() => {
const permissions = detail.value?.permissions;
@@ -300,24 +490,32 @@ async function load() {
}
function editAndResubmit() {
const flowCode = detail.value?.flowCode;
const docType = detail.value?.docType;
const page =
docType === 'completion'
? 'form-completion'
: docType === 'coordination'
? 'form-coordination'
: '';
(flowCode && EDIT_PAGES[flowCode]) ||
(docType && EDIT_PAGES[docType]) ||
(docType === 'coordination' ? 'form-coordination' : '');
if (!page) {
showError('该单据类型暂不支持在线修改', null);
return;
}
const query =
page === 'form-coordination' && flowCode
? '&flowCode=' + flowCode
: '';
uni.navigateTo({
url: `/packageApproval/pages/${page}?mode=edit&id=` + instanceId.value
url: `/packageApproval/pages/${page}?mode=edit&id=` + instanceId.value + query
});
}
function openApprove() {
opinion.value = '';
writableFields.value = detail.value?.permissions?.writableFields || [];
Object.keys(fieldValues).forEach(key => delete fieldValues[key]);
for (const field of writableFields.value) {
fieldValues[field.field] = field.value || '';
}
actionDialog.value = 'approve';
}
@@ -344,6 +542,45 @@ function closeDialog() {
actionDialog.value = '';
}
/** 回填字段:未填的可选字段不提交 */
function collectFieldValues(): Record<string, string> | undefined {
if (writableFields.value.length === 0) {
return undefined;
}
const values: Record<string, string> = {};
for (const field of writableFields.value) {
const value = (fieldValues[field.field] || '').trim();
if (value) {
values[field.field] = value;
}
}
return Object.keys(values).length > 0 ? values : undefined;
}
async function confirmBackfill() {
const taskId = detail.value?.myTask?.id;
if (!taskId) {
showError('任务已处理,请刷新后重试', null);
return;
}
const fields = collectFieldValues();
if (!fields) {
uni.showToast({ title: '请至少填写一个字段', icon: 'none' });
return;
}
submitting.value = true;
try {
const response = await backfillTask(taskId, fields);
actionDialog.value = '';
uni.showToast({ title: response.message || '回填成功', icon: 'none' });
await load();
} catch (error: any) {
showError(error?.message || '回填失败', null);
} finally {
submitting.value = false;
}
}
async function confirmAction() {
const taskId = detail.value?.myTask?.id;
if (!taskId) {
@@ -355,10 +592,21 @@ async function confirmAction() {
uni.showToast({ title: '请填写驳回意见', icon: 'none' });
return;
}
const fields = isApprove ? collectFieldValues() : undefined;
if (isApprove) {
// 通过时必填回填字段必须已填(服务端会再校验一次)
const missing = writableFields.value.find(
field => field.required && !(fieldValues[field.field] || '').trim()
);
if (missing) {
uni.showToast({ title: '请填写' + missing.label, icon: 'none' });
return;
}
}
submitting.value = true;
try {
const response = isApprove
? await approveTask(taskId, opinion.value.trim() || undefined)
? await approveTask(taskId, opinion.value.trim() || undefined, fields)
: await rejectTask(taskId, opinion.value.trim());
actionDialog.value = '';
uni.showToast({
@@ -478,6 +726,28 @@ function openAttachment(url?: string) {
// #endif
}
/** 长文本字段(事由/内容/条款等)单独占行并保留换行 */
function isLongField(field: ApprovalField) {
const value = field.value || '';
return value.length > 40 || value.indexOf('\n') >= 0;
}
/** 明细单元格展示:枚举取中文、布尔取是/否、空值占位 */
function displayLineValue(row: Record<string, any>, column: ApprovalLineColumn) {
const value = row[column.key];
if (value === undefined || value === null || value === '') {
return '—';
}
if (column.type === 'BOOL') {
return value === true || value === 1 || value === 'true' ? '是' : '否';
}
if (column.type === 'ENUM' && column.options) {
const option = column.options.find(item => item.value === String(value));
return option ? option.label : String(value);
}
return String(value);
}
function formatTime(value?: string) {
if (!value) {
return '—';
@@ -503,6 +773,13 @@ function formatFee(value?: number) {
return Number(value).toFixed(2);
}
function formatAmount(value?: number | null) {
if (value === undefined || value === null) {
return '0.00';
}
return Number(value).toFixed(2);
}
function formatSize(value?: number) {
if (!value) {
return '0 B';
@@ -578,12 +855,21 @@ function formatSize(value?: number) {
font-size: 26rpx;
}
.row.row-block {
flex-direction: column;
}
.label {
width: 160rpx;
color: #909399;
flex-shrink: 0;
}
.row-block .label {
width: auto;
margin-bottom: 6rpx;
}
.value {
flex: 1;
color: #303133;
@@ -595,6 +881,158 @@ function formatSize(value?: number) {
line-height: 1.6;
}
.table-scroll {
width: 100%;
}
.table {
display: inline-flex;
flex-direction: column;
min-width: 100%;
}
.table-row {
display: flex;
border-bottom: 1rpx solid #f0f0f0;
}
.table-head {
background: #fafbfc;
}
.table-cell {
flex-shrink: 0;
min-width: 160rpx;
max-width: 320rpx;
padding: 14rpx 12rpx;
font-size: 24rpx;
color: #303133;
word-break: break-all;
}
.table-head .table-cell {
color: #909399;
font-weight: 600;
}
.table-cell.cell-no {
min-width: 60rpx;
max-width: 60rpx;
text-align: center;
color: #909399;
}
.summary-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 16rpx;
font-size: 26rpx;
}
.summary-label {
color: #909399;
}
.summary-value {
color: #d81e06;
font-weight: 600;
}
.group {
padding: 16rpx 0;
border-bottom: 1rpx solid #f0f0f0;
}
.group-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.group-name {
font-size: 28rpx;
font-weight: 600;
color: #303133;
}
.group-progress {
font-size: 24rpx;
padding: 4rpx 14rpx;
border-radius: 999rpx;
background: #fff7e6;
color: #d46b08;
}
.group-node {
display: block;
margin-top: 6rpx;
font-size: 24rpx;
color: #909399;
}
.group-members {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 12rpx;
}
.member {
padding: 6rpx 16rpx;
font-size: 22rpx;
border-radius: 999rpx;
background: #f2f3f5;
color: #606266;
}
.member-DONE {
background: #f6ffed;
color: #389e0d;
}
.member-PENDING {
background: #fff7e6;
color: #d46b08;
}
.change {
padding: 14rpx 0;
border-bottom: 1rpx solid #f0f0f0;
}
.change-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.change-field {
font-size: 26rpx;
color: #303133;
font-weight: 600;
}
.change-operator {
font-size: 22rpx;
color: #909399;
}
.change-value {
display: block;
margin-top: 8rpx;
font-size: 26rpx;
color: #d81e06;
word-break: break-all;
}
.change-time {
display: block;
margin-top: 6rpx;
font-size: 22rpx;
color: #909399;
}
.attachment {
display: flex;
align-items: center;
@@ -779,7 +1217,9 @@ function formatSize(value?: number) {
}
.dialog {
width: 600rpx;
width: 640rpx;
max-height: 80vh;
overflow-y: auto;
padding: 32rpx;
border-radius: 16rpx;
background: #ffffff;
@@ -793,6 +1233,34 @@ function formatSize(value?: number) {
color: #303133;
}
.dialog-fields {
margin-top: 16rpx;
}
.dialog-field {
margin-top: 16rpx;
}
.dialog-field-label {
display: block;
margin-bottom: 10rpx;
font-size: 26rpx;
color: #303133;
}
.required {
color: #d81e06;
margin-left: 4rpx;
}
.dialog-field-input {
height: 68rpx;
padding: 0 16rpx;
font-size: 26rpx;
background: #f5f6f8;
border-radius: 12rpx;
}
.dialog-input {
width: 100%;
height: 180rpx;
@@ -1,6 +1,10 @@
<template>
<view class="form-page">
<uni-card :border="false" :padding="'16px'" title="基本信息">
<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>
@@ -110,6 +114,7 @@ import { useUserStore } from '@/store/modules/user';
import {
getInstance,
saveCoordination,
saveDocument,
submitInstance
} from '@/api/approval/instance';
import {
@@ -132,6 +137,8 @@ const categories = [
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({
@@ -150,6 +157,9 @@ 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)
);
@@ -160,6 +170,11 @@ const selectedOrgName = computed(
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();
@@ -203,10 +218,19 @@ async function loadDetail() {
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;
form.title = detail.title || '';
form.content = detail.content || '';
form.remark = detail.remark || '';
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) {
@@ -214,6 +238,11 @@ async function loadDetail() {
}
}
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 '请选择受理部门';
@@ -244,13 +273,18 @@ async function ensureDraft(): Promise<number> {
}
saving.value = true;
try {
const id = await saveCoordination({
// 内部流程仍走一期端点,外部流程走通用草稿端点(同一张表、同一份表单)
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;
+665
View File
@@ -0,0 +1,665 @@
<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>
<textarea
v-model="form.applyReason"
class="field-textarea"
maxlength="500"
placeholder="请输入申请事由"
/>
<text class="counter">{{ form.applyReason.length }}/500</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>
<picker
:range="couponTypeLabels"
:value="couponTypeIndex(row)"
@change="onCouponTypeChange($event, row)"
>
<view class="field-input picker">
{{ couponTypeLabel(row.couponType) || '请选择券种' }}
</view>
</picker>
</view>
<view class="line-row">
<view class="line-col">
<text class="field-label">数量<text class="required">*</text></text>
<input
v-model="row.quantity"
class="field-input"
type="number"
placeholder="≥1 的整数"
/>
</view>
<view class="line-col">
<text class="field-label">有效期至<text class="required">*</text></text>
<uni-datetime-picker
v-model="row.validUntil"
type="date"
placeholder="截止日期"
/>
</view>
</view>
</view>
<view v-if="lines.length === 0" class="empty">请至少添加一行券种</view>
<button class="add-button" @click="addLine">
添加券种({{ lines.length }}/{{ couponTypes.length }})
</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, reactive, 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 = 'coupon';
/** 7 类固定券种(ADR 0013),编码与后端 COUPON_TYPES 一一对应 */
const couponTypes = [
{ value: 'SWIMMING_DIVING', label: '游泳跳水馆单次' },
{ value: 'TENNIS_CENTER', label: '网球中心' },
{ value: 'TRAINING_HALL', label: '综合训练馆' },
{ value: 'FIVE_A_SIDE', label: '五人制足球场单次' },
{ value: 'GYM_BIWEEKLY', label: '威拓普健身馆VIP双周卡' },
{ value: 'GYM_WEEKLY', label: '威拓普健身馆周卡' },
{ value: 'GYM_MONTHLY', label: '威拓普健身馆月卡' }
];
interface CouponLine {
key: number;
couponType: string;
quantity: string;
validUntil: 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({ applyReason: '' });
const lines = ref<CouponLine[]>([]);
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 couponTypeLabels = couponTypes.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>): CouponLine {
return {
key: lineKey++,
couponType: textOf(source?.couponType),
quantity: textOf(source?.quantity),
validUntil: textOf(source?.validUntil).substring(0, 10)
};
}
function addLine() {
if (lines.value.length >= couponTypes.length) {
showError('券种最多 ' + couponTypes.length + ' 类,每类一行', null);
return;
}
lines.value.push(createLine());
}
function removeLine(index: number) {
lines.value.splice(index, 1);
}
function couponTypeIndex(row: CouponLine): number {
const index = couponTypes.findIndex(item => item.value === row.couponType);
return index >= 0 ? index : 0;
}
function couponTypeLabel(value: string): string {
const option = couponTypes.find(item => item.value === value);
return option ? option.label : '';
}
function onCouponTypeChange(event: { detail: { value: string | number } }, row: CouponLine) {
const option = couponTypes[Number(event.detail.value)];
row.couponType = option ? option.value : '';
}
async function loadDetail() {
try {
const detail = await getInstance(instanceId.value);
if (!detail.permissions?.canEdit) {
showError('当前状态不可修改', null);
return;
}
const fields = detail.document?.fields || [];
form.applyReason = fieldValue(fields, 'applyReason');
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) : '';
}
function validate(): string | null {
if (!form.applyReason.trim()) {
return '请输入申请事由';
}
if (form.applyReason.trim().length > 500) {
return '申请事由不能超过500字';
}
if (lines.value.length === 0) {
return '请至少添加一行券种';
}
const used = new Set<string>();
for (let index = 0; index < lines.value.length; index++) {
const row = lines.value[index];
const lineNo = index + 1;
if (!row.couponType) {
return '第' + lineNo + '行请选择券种';
}
if (used.has(row.couponType)) {
return '第' + lineNo + '行券种重复:' + couponTypeLabel(row.couponType) + ' 每类最多一行';
}
used.add(row.couponType);
if (!/^\d+$/.test(row.quantity.trim())) {
return '第' + lineNo + '行数量必须为整数';
}
if (Number(row.quantity) < 1) {
return '第' + lineNo + '行数量必须大于等于1';
}
if (!row.validUntil) {
return '第' + lineNo + '行请选择有效期至';
}
}
return null;
}
function buildLines(): Array<Record<string, any>> {
return lines.value.map(row => ({
couponType: row.couponType,
quantity: Number(row.quantity),
validUntil: row.validUntil
}));
}
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,
applyReason: form.applyReason.trim(),
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;
}
.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;
}
.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;
}
.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;
}
.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>
+749
View File
@@ -0,0 +1,749 @@
<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>
+554
View File
@@ -0,0 +1,554 @@
<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="auto-row">
<text class="label">受理部门</text>
<text class="value">财务部</text>
</view>
<view class="field">
<text class="field-label">金额(元)<text class="required">*</text></text>
<input
v-model="form.amount"
class="field-input"
type="digit"
placeholder="0.00,最多两位小数"
/>
</view>
<view class="field">
<text class="field-label">付款方<text class="required">*</text></text>
<input
v-model="form.payerName"
class="field-input"
type="text"
maxlength="200"
placeholder="请输入付款方名称"
/>
</view>
<view class="field">
<text class="field-label">事由<text class="required">*</text></text>
<textarea
v-model="form.reason"
class="field-textarea"
maxlength="500"
placeholder="请输入开票事由"
/>
<text class="counter">{{ form.reason.length }}/500</text>
</view>
<view class="field">
<text class="field-label">申请日期</text>
<uni-datetime-picker
v-model="form.applyDate"
type="date"
placeholder="选填,默认不填"
/>
</view>
<view class="field">
<text class="field-label">收据号</text>
<view class="readonly-value">{{ form.receiptNo || '由出纳审批时回填' }}</view>
</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 { 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 = 'receipt';
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({
amount: '',
payerName: '',
reason: '',
receiptNo: '',
applyDate: ''
});
const attachments = ref<ApprovalAttachment[]>([]);
const category = ref('VOUCHER');
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;
}
const fields = detail.document?.fields || [];
const amount = fieldValue(fields, 'amount');
form.amount = amount === '' ? '' : Number(amount).toFixed(2);
form.payerName = fieldValue(fields, 'payerName');
form.reason = fieldValue(fields, 'reason');
form.receiptNo = fieldValue(fields, 'receiptNo');
form.applyDate = fieldValue(fields, 'applyDate').substring(0, 10);
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) : '';
}
function validate(): string | null {
const amount = Number(form.amount);
if (!form.amount || Number.isNaN(amount)) {
return '请输入金额';
}
if (!/^\d+(\.\d{1,2})?$/.test(form.amount.trim())) {
return '金额必须为0到99999999.99之间的两位小数';
}
if (amount < 0 || amount > 99999999.99) {
return '金额必须为0到99999999.99之间的两位小数';
}
if (!form.payerName.trim()) {
return '请输入付款方';
}
if (form.payerName.trim().length > 200) {
return '付款方不能超过200字';
}
if (!form.reason.trim()) {
return '请输入事由';
}
if (form.reason.trim().length > 500) {
return '事由不能超过500字';
}
if (form.receiptNo.trim().length > 64) {
return '收据号不能超过64字';
}
return null;
}
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,
amount: form.amount.trim(),
payerName: form.payerName.trim(),
reason: form.reason.trim(),
receiptNo: form.receiptNo.trim() || undefined,
applyDate: form.applyDate || undefined
});
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;
line-height: 72rpx;
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;
}
.readonly-value {
height: 72rpx;
padding: 0 20rpx;
font-size: 28rpx;
line-height: 72rpx;
color: #909399;
background: #f7f8fa;
border-radius: 12rpx;
}
.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>
@@ -0,0 +1,817 @@
<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>
<textarea
v-model="form.settlementReason"
class="field-textarea small"
maxlength="500"
placeholder="请输入结算事由"
/>
<text class="counter">{{ form.settlementReason.length }}/500</text>
</view>
<view class="field">
<text class="field-label">结算期间</text>
<input
v-model="form.settlementPeriod"
class="field-input"
type="text"
maxlength="100"
placeholder="选填,如 2026年1月-6月"
/>
</view>
<view class="field">
<text class="field-label">结算对象<text class="required">*</text></text>
<input
v-model="form.counterpartyName"
class="field-input"
type="text"
maxlength="200"
placeholder="请输入结算对象名称"
/>
</view>
<view class="field">
<text class="field-label">合同号</text>
<input
v-model="form.contractNo"
class="field-input"
type="text"
maxlength="100"
placeholder="选填,合同/项目编号"
/>
</view>
</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.itemName"
class="field-input"
type="text"
maxlength="200"
placeholder="请输入项目名称"
/>
</view>
<view class="field tight">
<text class="field-label">规格说明</text>
<input
v-model="row.spec"
class="field-input"
type="text"
maxlength="200"
placeholder="选填"
/>
</view>
<view class="line-row">
<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 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>
<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 = 'settlement';
const MAX_LINES = 100;
interface SettlementLine {
key: number;
itemName: string;
spec: string;
quantity: string;
unit: 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({
settlementReason: '',
settlementPeriod: '',
counterpartyName: '',
contractNo: ''
});
const lines = ref<SettlementLine[]>([]);
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 createLine(source?: Record<string, any>): SettlementLine {
return {
key: lineKey++,
itemName: textOf(source?.itemName),
spec: textOf(source?.spec),
quantity: textOf(source?.quantity),
unit: textOf(source?.unit),
unitPrice: textOf(source?.unitPrice),
remark: textOf(source?.remark)
};
}
function textOf(value: any): string {
return value === undefined || value === null ? '' : String(value);
}
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: SettlementLine): 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.settlementReason = fieldValue(fields, 'settlementReason');
form.settlementPeriod = fieldValue(fields, 'settlementPeriod');
form.counterpartyName = fieldValue(fields, 'counterpartyName');
form.contractNo = fieldValue(fields, 'contractNo');
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) : '';
}
function validate(): string | null {
if (!form.settlementReason.trim()) {
return '请输入结算事由';
}
if (form.settlementReason.trim().length > 500) {
return '结算事由不能超过500字';
}
if (form.settlementPeriod.trim().length > 100) {
return '结算期间不能超过100字';
}
if (!form.counterpartyName.trim()) {
return '请输入结算对象';
}
if (form.counterpartyName.trim().length > 200) {
return '结算对象不能超过200字';
}
if (form.contractNo.trim().length > 100) {
return '合同号不能超过100字';
}
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.spec.trim().length > 200) {
return '第' + lineNo + '行规格说明不能超过200字';
}
if (!isValidDecimal(row.quantity, 3)) {
return '第' + lineNo + '行数量必须为数字,最多3位小数';
}
if (!row.unit.trim()) {
return '第' + lineNo + '行单位不能为空';
}
if (row.unit.trim().length > 20) {
return '第' + lineNo + '行单位不能超过20字';
}
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(),
spec: row.spec.trim() || undefined,
quantity: row.quantity.trim(),
unit: row.unit.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,
settlementReason: form.settlementReason.trim(),
settlementPeriod: form.settlementPeriod.trim() || undefined,
counterpartyName: form.counterpartyName.trim(),
contractNo: form.contractNo.trim() || 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-textarea {
width: 100%;
height: 220rpx;
padding: 16rpx 20rpx;
font-size: 28rpx;
background: #f7f8fa;
border-radius: 12rpx;
box-sizing: border-box;
}
.field-textarea.small {
height: 160rpx;
}
.counter {
display: block;
margin-top: 8rpx;
text-align: right;
font-size: 24rpx;
color: #909399;
}
.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>
+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>
+711
View File
@@ -0,0 +1,711 @@
<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>
<view class="chips">
<view
v-for="item in applicantTypes"
:key="item.value"
class="chip"
:class="{ active: form.applicantType === item.value }"
@click="form.applicantType = item.value"
>
{{ item.label }}
</view>
</view>
</view>
<view class="field">
<text class="field-label">申请单位(人)名称<text class="required">*</text></text>
<input
v-model="form.applicantName"
class="field-input"
type="text"
maxlength="100"
:placeholder="form.applicantType === 'PERSON' ? '请输入个人姓名' : '请输入单位名称'"
/>
<text class="counter">{{ form.applicantName.length }}/100</text>
</view>
<view class="field">
<text class="field-label">使用开始时间<text class="required">*</text></text>
<uni-datetime-picker
v-model="form.useStartTime"
type="datetime"
placeholder="选择开始时间"
/>
</view>
<view class="field">
<text class="field-label">使用结束时间<text class="required">*</text></text>
<uni-datetime-picker
v-model="form.useEndTime"
type="datetime"
placeholder="选择结束时间(须与开始同一天)"
/>
</view>
<view class="field">
<text class="field-label">拟申请场地<text class="required">*</text></text>
<picker
:range="siteLabels"
:value="siteIndex"
@change="onSiteChange"
>
<view class="field-input picker">
{{ form.siteName || (sitesLoading ? '场地加载中...' : '请选择场地') }}
</view>
</picker>
</view>
<view class="field">
<text class="field-label">用途说明<text class="required">*</text></text>
<textarea
v-model="form.purpose"
class="field-textarea"
maxlength="1000"
placeholder="请输入用途说明"
/>
<text class="counter">{{ form.purpose.length }}/1000</text>
</view>
<view class="field">
<text class="field-label">活动费用(元)<text class="required">*</text></text>
<input
v-model="form.activityFee"
class="field-input"
type="digit"
placeholder="0.00,最多两位小数"
/>
</view>
</uni-card>
<uni-card :border="false" :padding="'16px'" title="相关事项说明">
<view v-for="(term, index) in terms" :key="index" class="term">
<text class="term-index">{{ index + 1 }}.</text>
<text class="term-text">{{ term }}</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 { useUserStore } from '@/store/modules/user';
import { getInstance, saveDocument, submitInstance } from '@/api/approval/instance';
import { deleteAttachment, uploadAttachment } from '@/api/approval/attachment';
import { listBookingSite } from '@/api/booking/bookingSite';
import type { ApprovalAttachment } from '@/api/approval/model';
import type { BookingSite } from '@/api/booking/bookingSite/model';
import { showError, showSuccess } from '@/utils/app';
const FLOW_CODE = 'venue';
const userStore = useUserStore();
const categories = [
{ value: 'SIGNED', label: '签字件' },
{ value: 'VOUCHER', label: '凭证' },
{ value: 'OTHER', label: '其他' }
];
const applicantTypes = [
{ value: 'ORG', label: '单位' },
{ value: 'PERSON', label: '个人' }
];
const mode = ref<'create' | 'edit'>('create');
const instanceId = ref(0);
const form = reactive({
applicantName: '',
applicantType: 'ORG',
useStartTime: '',
useEndTime: '',
siteId: 0,
siteName: '',
purpose: '',
activityFee: ''
});
const sites = ref<BookingSite[]>([]);
const sitesLoading = ref(false);
const attachments = ref<ApprovalAttachment[]>([]);
const category = ref('OTHER');
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 siteLabels = computed(() => sites.value.map(item => item.name || '未命名场地'));
const siteIndex = computed(() => {
const index = sites.value.findIndex(item => item.id === form.siteId);
return index >= 0 ? index : 0;
});
/**
* 模板 8 条费用与安全条款(ADR 0009 / VenueTerms),只展示不填报;
* 第 1 条渲染实际场地名与活动费用。
*/
const terms = computed(() => {
const site = form.siteName.trim() || '(未选场地)';
const fee = form.activityFee.trim() ? Number(form.activityFee).toFixed(2) : '待定';
return [
'使用' +
site +
'(场地),活动费用为' +
fee +
'元,账户信息如下:单位名称:南宁威宁资产经营集团有限责任公司;' +
'开户行:中国建设银行股份有限公司南宁淡村路支行;' +
'银行账号:4505 0160 4952 0900 0033。',
'费用交付完成后方可进行布场。',
'使用方已对体育场地的状况进行了充分了解,愿意按场地的现状使用。',
'使用方入场前应对所有进场人员的身体状况等进行核实,确认其身体状况符合进场运动、工作的条件。' +
'如使用方组织的人员因故意、疏忽等原因导致进场人员的身体状况出现问题的,由使用方承担全部法律责任,' +
'由此导致管理方遭受损失的,管理方有权追偿。',
'对场地的使用和车辆进出、停放及人员进出须遵守管理方的规定,并服从管理方的管理。' +
'使用方人员参加活动期间不得乱扔垃圾,除运动垃圾外,厨余垃圾、广告垃圾清理费另行结算。',
'如需使用空调,应另行与南宁威宁文化体育发展有限公司现场签单结算,收费标准为2000元/小时,' +
'使用不满1小时的,按1小时结算。',
'广西体育中心停车前三小时免费,如需使用停车票,应与南宁威宁文化体育发展有限公司另行结算,' +
'收费标准为5元/天/车。',
'如产生空调费以及停车票费用,转账账户信息如下:单位名称:南宁威宁文化体育发展有限公司;' +
'开户行:中国建设银行股份有限公司南宁淡村路支行;' +
'银行账号:4505 0160 4952 0900 0003。'
];
});
onLoad(async options => {
mode.value = options?.mode === 'edit' ? 'edit' : 'create';
instanceId.value = Number(options?.id) || 0;
// 先拿场地主数据,再回填草稿,避免按名称匹配 siteId 时主数据还没到
await loadSites();
if (instanceId.value) {
loadDetail();
}
});
async function loadSites() {
sitesLoading.value = true;
try {
sites.value = await listBookingSite();
} catch (error: any) {
showError(error?.message || '场地主数据加载失败', null);
} finally {
sitesLoading.value = false;
}
}
function onSiteChange(event: { detail: { value: string | number } }) {
const site = sites.value[Number(event.detail.value)];
if (site) {
form.siteId = site.id || 0;
form.siteName = site.name || '';
}
}
async function loadDetail() {
try {
const detail = await getInstance(instanceId.value);
if (!detail.permissions?.canEdit) {
showError('当前状态不可修改', null);
return;
}
const fields = detail.document?.fields || [];
form.applicantName = fieldValue(fields, 'applicantName');
form.applicantType = applicantTypeCode(fieldValue(fields, 'applicantType'));
form.useStartTime = fieldValue(fields, 'useStartTime');
form.useEndTime = fieldValue(fields, 'useEndTime');
form.siteName = fieldValue(fields, 'siteName');
form.purpose = fieldValue(fields, 'purpose');
form.activityFee = normalizeFee(fieldValue(fields, 'activityFee'));
syncSiteIdByName();
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) : '';
}
/** 后端返回的是展示名(单位/个人),这里回填为编码 */
function applicantTypeCode(label: string): string {
return label === '个人' ? 'PERSON' : 'ORG';
}
function normalizeFee(value: string): string {
if (!value) {
return '';
}
const matched = value.replace(/[^0-9.]/g, '');
return matched ? Number(matched).toFixed(2) : '';
}
/** 详情只回传场地名称快照,按名称匹配主数据回填 siteId(匹配不到则保留 0,提交时要求重选) */
function syncSiteIdByName() {
const matched = sites.value.find(item => item.name === form.siteName);
form.siteId = matched?.id || 0;
}
function validate(): string | null {
if (!form.applicantType) {
return '请选择申请单位(人)类型';
}
if (!form.applicantName.trim()) {
return '请输入申请单位(人)名称';
}
if (form.applicantName.trim().length > 100) {
return '申请单位(人)名称不能超过100字';
}
if (!form.useStartTime) {
return '请选择使用开始时间';
}
if (!form.useEndTime) {
return '请选择使用结束时间';
}
if (form.useEndTime <= form.useStartTime) {
return '使用结束时间必须晚于使用开始时间';
}
if (form.useStartTime.substring(0, 10) !== form.useEndTime.substring(0, 10)) {
return '使用时间不能跨自然日,请按单日时段填写';
}
if (!form.siteId) {
return '请选择场地';
}
if (!form.purpose.trim()) {
return '请输入用途说明';
}
if (form.purpose.trim().length > 1000) {
return '用途说明不能超过1000字';
}
const fee = Number(form.activityFee);
if (!form.activityFee || Number.isNaN(fee)) {
return '请输入活动费用';
}
if (!/^\d+(\.\d{1,2})?$/.test(form.activityFee.trim())) {
return '活动费用必须为0到99999999.99之间的两位小数';
}
if (fee < 0 || fee > 99999999.99) {
return '活动费用必须为0到99999999.99之间的两位小数';
}
return null;
}
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,
applicantName: form.applicantName.trim(),
applicantType: form.applicantType,
useStartTime: form.useStartTime,
useEndTime: form.useEndTime,
siteId: form.siteId,
siteName: form.siteName.trim(),
purpose: form.purpose.trim(),
activityFee: form.activityFee.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;
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;
}
.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;
}
.term {
display: flex;
margin-top: 14rpx;
font-size: 24rpx;
line-height: 1.7;
color: #606266;
}
.term-index {
width: 40rpx;
flex-shrink: 0;
color: #909399;
}
.term-text {
flex: 1;
word-break: break-all;
}
.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>
+124 -18
View File
@@ -16,6 +16,9 @@
<picker :range="statusLabels" :value="statusIndex" @change="onStatusChange">
<view class="filter">{{ statusLabels[statusIndex] }}</view>
</picker>
<picker :range="flowLabels" :value="flowIndex" @change="onFlowChange">
<view class="filter">{{ flowLabels[flowIndex] }}</view>
</picker>
<input
v-model="keywords"
class="search"
@@ -41,6 +44,10 @@
{{ item.statusName }}
</text>
</view>
<view class="item-row">
<text class="label">单据类型</text>
<text class="value">{{ item.docTypeName || '—' }}</text>
</view>
<view class="item-row">
<text class="label">单号</text>
<text class="value">{{ item.bizNo || '—' }}</text>
@@ -67,13 +74,26 @@
</view>
<view v-if="activeTab === 'mine' && canSubmit" class="create-bar">
<button class="create-button" @click="createCompletion">
新建确认单
</button>
<button class="create-button primary" @click="createCoordination">
新建协调单
<button class="create-button primary" @click="createSheetVisible = true">
新建单据
</button>
</view>
<!-- 9 种单据超过 showActionSheet 上限(微信最多 6 项),用弹层选择 -->
<view v-if="createSheetVisible" class="sheet-mask" @click="createSheetVisible = false">
<view class="sheet" @click.stop>
<text class="sheet-title">选择单据类型</text>
<view
v-for="item in createOptions"
:key="item.flowCode"
class="sheet-item"
@click="createDocument(item)"
>
{{ item.label }}
</view>
<button class="sheet-cancel" @click="createSheetVisible = false">取消</button>
</view>
</view>
</view>
</template>
@@ -91,6 +111,31 @@ import { hasPermission } from '@/utils/permission';
type TabKey = 'todo' | 'mine' | 'done';
/** 新建入口:流程编码 → 表单页(标签取流程定义名称) */
const createOptions: CreateOption[] = [
{ flowCode: 'completion', label: '业务完成确认单', page: 'form-completion', query: '' },
{ flowCode: 'coordination_internal', label: '内部协调单', page: 'form-coordination', query: '' },
{
flowCode: 'coordination_external',
label: '外部协调单',
page: 'form-coordination',
query: '&flowCode=coordination_external'
},
{ flowCode: 'settlement', label: '结算单', page: 'form-settlement', query: '' },
{ flowCode: 'receipt', label: '收据申请单', page: 'form-receipt', query: '' },
{ flowCode: 'vehicle', label: '车辆免收费登记表', page: 'form-vehicle', query: '' },
{ flowCode: 'venue', label: '场地使用申请表', page: 'form-venue', query: '' },
{ flowCode: 'coupon', label: '体验券领用申请单', page: 'form-coupon', query: '' },
{ flowCode: 'facility', label: '设施价格表', page: 'form-facility', query: '' }
];
/** 流程筛选项:标签取流程名(ADR 0008),空值表示全部 */
const flowOptions = [
{ value: '', label: '全部类型' },
...createOptions.map(item => ({ value: item.flowCode, label: item.label }))
];
const flowLabels = flowOptions.map(item => item.label);
const tabs: Array<{ key: TabKey; label: string }> = [
{ key: 'todo', label: '待办' },
{ key: 'mine', label: '我发起的' },
@@ -120,6 +165,8 @@ const emptyText = computed(() => {
return '暂无单据';
});
const statusIndex = ref(0);
const flowIndex = ref(0);
const createSheetVisible = ref(false);
const keywords = ref('');
const list = ref<ApprovalItem[]>([]);
const page = ref(1);
@@ -162,6 +209,30 @@ function onStatusChange(event: { detail: { value: string | number } }) {
load(true);
}
function onFlowChange(event: { detail: { value: string | number } }) {
flowIndex.value = Number(event.detail.value);
load(true);
}
interface CreateOption {
flowCode: string;
label: string;
page: string;
query: string;
}
/** 新建单据:从弹层选类型后跳对应表单页 */
function createDocument(option: CreateOption) {
createSheetVisible.value = false;
uni.navigateTo({
url:
'/packageApproval/pages/' +
option.page +
'?mode=create' +
option.query
});
}
async function load(reset: boolean) {
if (loading.value && !reset) {
return;
@@ -178,7 +249,8 @@ async function load(reset: boolean) {
page: page.value,
limit,
keywords: keywords.value || undefined,
status: statusOptions[statusIndex.value].value || undefined
status: statusOptions[statusIndex.value].value || undefined,
flowCode: flowOptions[flowIndex.value].value || undefined
};
const result =
tab === 'todo'
@@ -214,18 +286,6 @@ function openDetail(id: number) {
uni.navigateTo({ url: '/packageApproval/pages/detail?id=' + id });
}
function createCompletion() {
uni.navigateTo({
url: '/packageApproval/pages/form-completion?mode=create'
});
}
function createCoordination() {
uni.navigateTo({
url: '/packageApproval/pages/form-coordination?mode=create'
});
}
function formatTime(value?: string) {
if (!value) {
return '—';
@@ -407,4 +467,50 @@ function formatTime(value?: string) {
color: #ffffff;
border-color: #d81e06;
}
.sheet-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: flex-end;
z-index: 100;
}
.sheet {
width: 100%;
max-height: 80vh;
overflow-y: auto;
padding: 24rpx 32rpx calc(24rpx + env(safe-area-inset-bottom));
background: #ffffff;
border-radius: 24rpx 24rpx 0 0;
}
.sheet-title {
display: block;
padding: 12rpx 0 20rpx;
text-align: center;
font-size: 28rpx;
color: #909399;
}
.sheet-item {
padding: 26rpx 0;
text-align: center;
font-size: 30rpx;
color: #303133;
border-top: 1rpx solid #f0f0f0;
}
.sheet-cancel {
margin-top: 24rpx;
font-size: 28rpx;
border-radius: 12rpx;
background: #f7f8fa;
color: #303133;
border: 1rpx solid #dcdfe6;
}
</style>
+42
View File
@@ -70,6 +70,48 @@
"navigationBarTitleText": "派单",
"enablePullDownRefresh": false
}
},
{
"path": "pages/form-settlement",
"style": {
"navigationBarTitleText": "结算单",
"enablePullDownRefresh": false
}
},
{
"path": "pages/form-receipt",
"style": {
"navigationBarTitleText": "收据申请单",
"enablePullDownRefresh": false
}
},
{
"path": "pages/form-vehicle",
"style": {
"navigationBarTitleText": "车辆免收费登记表",
"enablePullDownRefresh": false
}
},
{
"path": "pages/form-venue",
"style": {
"navigationBarTitleText": "场地使用申请表",
"enablePullDownRefresh": false
}
},
{
"path": "pages/form-coupon",
"style": {
"navigationBarTitleText": "体验券领用申请单",
"enablePullDownRefresh": false
}
},
{
"path": "pages/form-facility",
"style": {
"navigationBarTitleText": "设施价格表",
"enablePullDownRefresh": false
}
}
]
},