feat: 员工端二期 7 张单据表单页与通用详情渲染(#46)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user