增强办公,新增审批,缺少电子签章功能

This commit is contained in:
2026-04-20 15:56:29 +08:00
parent a026ef7a54
commit cd71a3a457
18 changed files with 1791 additions and 537 deletions

View File

@@ -1,43 +1,145 @@
<template>
<request-form
title="拨款申请"
subtitle="填写拨款信息,支持手机端快速提交"
biz-type="appropriation"
:request-api="submitApply"
:initial-form="initialForm"
:sections="sections"
:flow-fields="flowFields"
/>
<view class="page">
<view class="card">
<view class="card-title">基础信息</view>
<view class="field">
<text class="label">拨款类型<text class="req">*</text></text>
<picker mode="selector" :range="appropriationTypes" @change="onAppropriationTypeChange">
<view class="input-like clickable">{{ appropriationType || '请选择拨款类型' }}</view>
</picker>
</view>
<view class="field">
<text class="label">拨款金额<text class="req">*</text></text>
<input class="input" type="text" :value="amount" placeholder="请输入金额" @input="onAmountInput" />
</view>
<view class="field">
<text class="label">附件</text>
<view class="input-like clickable" @tap="openFilePicker">点击上传附件</view>
<view v-if="uploadedFiles.length" class="file-list">
<view v-for="(file, idx) in uploadedFiles" :key="file.ossId || file.url || idx" class="file-item">
<text class="file-name">{{ file.originalName || file.fileName || file.name || file.url }}</text>
<text class="remove" @tap.stop="removeFile(idx)">删除</text>
</view>
</view>
</view>
</view>
<view class="card">
<view class="card-title">说明</view>
<view class="field">
<text class="label">用途说明<text class="req">*</text></text>
<textarea v-model="reason" class="textarea" placeholder="请说明拨款用途与依据" />
</view>
<view class="field">
<text class="label">备注</text>
<textarea v-model="remark" class="textarea" placeholder="可选" />
</view>
</view>
<view class="card">
<button class="btn primary" :loading="submitting" @tap="submit">提交申请</button>
</view>
<x-file-picker ref="globalFilePicker" @select="onGlobalFileSelect" />
</view>
</template>
<script>
import RequestForm from '@/components/hrm/RequestForm.vue'
import { addAppropriationReq } from '@/api/hrm/appropriation'
import { uploadFile } from '@/api/common/upload'
import XFilePicker from '@/components/x-native-uploader/x-file-picker.vue'
export default {
components: { RequestForm },
components: { XFilePicker },
data() {
return {
initialForm: { appropriationType: '', amount: '', reason: '', remark: '' },
sections: [
{ key: 'basic', title: '基础信息', fields: [
{ key: 'appropriationType', label: '拨款类型', type: 'select', required: true, placeholder: '请选择拨款类型', options: ['项目拨款', '部门拨款', '专项拨款', '备用金拨款', '其他'] },
{ key: 'amount', label: '拨款金额', type: 'input', inputType: 'digit', required: true, placeholder: '请输入金额' }
]},
{ key: 'desc', title: '说明', fields: [
{ key: 'reason', label: '用途说明', type: 'textarea', required: true, placeholder: '请说明拨款用途与依据' },
{ key: 'remark', label: '备注', type: 'textarea', required: false, placeholder: '可选' }
]}
],
flowFields: [
{ key: 'appropriationType', label: '拨款类型', required: true },
{ key: 'amount', label: '拨款金额', required: true },
{ key: 'reason', label: '用途说明', required: true }
]
appropriationTypes: ['项目拨款', '部门拨款', '专项拨款', '备用金拨款', '其他'],
appropriationType: '',
amount: '',
reason: '',
remark: '',
accessoryApplyIds: '',
uploadedFiles: [],
submitting: false
}
},
methods: { submitApply(payload) { return addAppropriationReq(payload) } }
methods: {
onAppropriationTypeChange(e) {
var idx = Number(e.detail.value)
this.appropriationType = this.appropriationTypes[idx] || ''
},
onAmountInput(e) {
this.amount = e.detail.value
},
openFilePicker() {
var picker = this.$refs.globalFilePicker
if (picker && picker.open) picker.open()
},
async onGlobalFileSelect(payload) {
var files = Array.isArray(payload) ? payload : [payload]
for (var i = 0; i < files.length; i++) {
var f = files[i]
if (!f) continue
try {
var uploaded = await uploadFile({
path: f.path || f.url || '',
name: f.name || f.fileName || 'file',
size: f.size || 0,
type: f.type || ''
})
this.uploadedFiles.push({
name: uploaded.fileName || f.name || f.fileName || 'file',
fileName: uploaded.fileName || f.fileName || f.name || '',
originalName: f.name || f.fileName || uploaded.fileName || '',
url: uploaded.url || f.path || f.url || '',
ossId: uploaded.ossId || uploaded.url || f.path || f.url || ''
})
} catch (e) {
uni.showToast({ title: e.message || '上传失败', icon: 'none' })
}
}
this.syncFileIds()
},
removeFile(idx) {
this.uploadedFiles.splice(idx, 1)
this.syncFileIds()
},
syncFileIds() {
this.accessoryApplyIds = this.uploadedFiles.map(function (f) { return f.ossId }).join(',')
},
async submit() {
if (!this.appropriationType) return uni.showToast({ title: '请选择拨款类型', icon: 'none' })
if (!this.amount) return uni.showToast({ title: '请输入拨款金额', icon: 'none' })
if (!this.reason) return uni.showToast({ title: '请填写用途说明', icon: 'none' })
this.submitting = true
try {
await addAppropriationReq({
appropriationType: this.appropriationType,
amount: this.amount,
accessoryApplyIds: this.accessoryApplyIds,
reason: this.reason,
remark: this.remark,
status: 'pending',
bizType: 'appropriation'
})
uni.showToast({ title: '提交成功', icon: 'success' })
setTimeout(() => uni.navigateBack({ delta: 1 }), 500)
} catch (e) {
uni.showToast({ title: e.message || '提交失败', icon: 'none' })
} finally {
this.submitting = false
}
}
}
}
</script>
<style scoped></style>
<style scoped>
.page{min-height:100vh;background:#f5f7fb;padding:24rpx;box-sizing:border-box}
.card{background:#fff;border-radius:20rpx;padding:24rpx;margin-bottom:20rpx}
.card-title{font-size:30rpx;font-weight:700;color:#111827;margin-bottom:16rpx}
.field{margin-bottom:18rpx}.label{display:block;margin-bottom:8rpx;font-size:24rpx;color:#6b7280}.req{color:#ef4444;margin-left:4rpx}.input-like,.input,.textarea{width:100%;box-sizing:border-box;border:1rpx solid #e5e7eb;border-radius:16rpx;background:#f9fafb;padding:20rpx;font-size:28rpx;color:#111827}.input{min-height:88rpx}.clickable{color:#111827}.textarea{min-height:160rpx}.file-list{margin-top:16rpx;display:flex;flex-direction:column;gap:12rpx}.file-item{display:flex;align-items:center;justify-content:space-between;padding:18rpx 18rpx;border-radius:16rpx;background:#f8fafc;border:1rpx solid #e5eefc}.file-name{flex:1;font-size:26rpx;color:#334155;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-right:16rpx}.remove{font-size:24rpx;color:#ef4444;flex-shrink:0}.btn.primary{width:100%;background:#1677ff;color:#fff;border-radius:16rpx}
</style>

View File

@@ -2,10 +2,6 @@
<view class="hrm-page">
<!-- 顶部统计栏 -->
<view class="summary-bar">
<view class="summary-left">
<view class="page-title">审批中心</view>
<view class="page-desc">集中查看与处理待办审批</view>
</view>
<view class="summary-right">
<view class="metric">
<view class="metric-value">{{ todoCount }}</view>

View File

@@ -1,13 +1,58 @@
<template>
<view class="approval-detail-page">
<!-- 动态渲染不同类型的审批详情组件 -->
<view class="flow-summary-card" v-if="detailData">
<view class="summary-head">
<text class="summary-title">审批信息</text>
<text class="status-tag" :class="statusClass(detailData.flowStatus)">{{ statusText(detailData.flowStatus) }}</text>
</view>
<view class="summary-grid">
<view class="summary-item">
<text class="label">是否通过</text>
<text class="value">{{ detailData.approved ? '已通过' : (detailData.flowStatus === 'rejected' ? '已驳回' : '待审批') }}</text>
</view>
<view class="summary-item">
<text class="label">当前状态</text>
<text class="value">{{ statusText(detailData.flowStatus) }}</text>
</view>
<view class="summary-item">
<text class="label">当前节点</text>
<text class="value">{{ detailData.currentNodeName || detailData.currentNodeId || '-' }}</text>
</view>
<view class="summary-item">
<text class="label">当前审批人</text>
<text class="value">{{ assigneeText }}</text>
</view>
</view>
</view>
<view class="flow-history-card" v-if="detailData && detailData.actionTimeline && detailData.actionTimeline.length">
<view class="card-title">审批流程</view>
<view class="timeline-item" v-for="(item, index) in detailData.actionTimeline" :key="item.actionId || index">
<view class="timeline-dot" :class="item.action"></view>
<view class="timeline-line" v-if="index !== detailData.actionTimeline.length - 1"></view>
<view class="timeline-content">
<view class="timeline-header">
<text class="timeline-name">{{ item.actionUserName || '未知审批人' }}</text>
<text class="timeline-time">{{ formatTime(item.createTime) }}</text>
</view>
<view class="timeline-meta">
<text>节点{{ item.nodeName || item.nodeId || '-' }}</text>
<text>动作{{ item.actionText || item.action || '-' }}</text>
</view>
<view class="timeline-meta">
<text>任务状态{{ statusText(item.taskStatus) }}</text>
</view>
<view class="timeline-remark" v-if="item.remark">意见{{ item.remark }}</view>
</view>
</view>
</view>
<component
:is="currentDetailComponent"
:bizId="bizId"
v-if="bizId && bizType"
></component>
<!-- 底部固定的审批操作按钮栏 -->
<view class="approval-btn-bar" v-if="canApprove">
<button class="btn reject-btn" @click="handleReject">驳回</button>
<button class="btn approve-btn" @click="handleApprove">通过</button>
@@ -21,17 +66,14 @@
import HRMSealDetail from '@/components/hrm/detailPanels/seal.vue'
import HRMTravelDetail from '@/components/hrm/detailPanels/travel.vue'
import HRMAppropriationDetail from '@/components/hrm/detailPanels/appropriation.vue'
import {
approveFlowTask,
listTodoFlowTask,
rejectFlowTask,
getTodoTaskByBiz,
getFlowTaskDetailByBiz,
} from '@/api/hrm/flow';
export default {
components: {
// 注册所有详情组件,供动态组件使用
HRMLeaveDetail,
HRMReimburseDetail,
HRMSealDetail,
@@ -42,73 +84,82 @@
return {
bizId: undefined,
bizType: undefined,
currentTask: undefined,
// 映射bizType到对应的组件名需和你的bizType实际值匹配可自行调整
detailData: null,
bizTypeComponentMap: {
leave: 'HRMLeaveDetail', // 请假
reimburse: 'HRMReimburseDetail', // 报销
seal: 'HRMSealDetail', // 用章
travel: 'HRMTravelDetail' ,// 差旅
leave: 'HRMLeaveDetail',
reimburse: 'HRMReimburseDetail',
seal: 'HRMSealDetail',
travel: 'HRMTravelDetail',
appropriation: 'HRMAppropriationDetail'
}
}
},
computed: {
// 计算属性根据bizType获取当前要渲染的组件名
currentDetailComponent() {
return this.bizTypeComponentMap[this.bizType] || '';
},
canApprove() {
console.log(this.currentTask, this.$store.getters.storeOaName, this.$store.getters.storeOaId)
return this.currentTask && this.currentTask.status === 'pending' &&
(this.currentTask?.assigneeUserName === this.$store.getters.storeOaName
|| this.currentTask?.assigneeUserId === this.$store.getters.storeOaId)
currentTask() {
return this.detailData?.currentTask || null;
},
canApprove() {
return this.currentTask && this.currentTask.status === 'pending' &&
(this.currentTask?.assigneeUserName === this.$store.getters.storeOaName
|| this.currentTask?.assigneeUserId === this.$store.getters.storeOaId)
},
assigneeText() {
return this.currentTask?.assigneeNickName || this.currentTask?.assigneeUserName || this.currentTask?.assigneeUserId || '-';
}
},
watch: {
bizId: {
immediate: true, // 页面加载时立即执行(原代码缺失,导致首次赋值不触发)
immediate: true,
handler(newVal) {
if (!newVal || !this.bizType) return;
// 获取当前审批任务信息
getTodoTaskByBiz(this.bizType, newVal)
getFlowTaskDetailByBiz(this.bizType, newVal)
.then(res => {
this.currentTask = res.data;
this.detailData = res.data || null;
})
.catch(err => {
uni.showToast({
title: '获取审批信息失败',
icon: 'none'
});
console.error('获取审批任务失败:', err);
console.error('获取审批详情失败:', err);
});
}
}
},
methods: {
/**
* 审批通过
*/
statusText(status) {
const map = {
pending: '审批中',
running: '流转中',
approved: '已通过',
rejected: '已驳回',
reject: '已驳回',
withdraw: '已撤回',
withdrawn: '已撤回'
};
return map[status] || status || '-';
},
statusClass(status) {
return status || 'default';
},
formatTime(time) {
if (!time) return '-';
const d = new Date(time);
return Number.isNaN(d.getTime()) ? time : `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;
},
handleApprove() {
if (!this.currentTask?.taskId) {
uni.showToast({ title: '暂无审批任务', icon: 'none' });
return;
}
// 二次确认
uni.showModal({
title: '确认通过',
content: '是否确定通过该审批?',
// 箭头函数保留this指向
success: (res) => {
if (res.confirm) {
approveFlowTask(this.currentTask.taskId)
.then(() => {
uni.showToast({ title: '审批通过成功' });
// 成功后返回上一页(可根据需求调整)
setTimeout(() => {
uni.navigateBack();
}, 1500);
setTimeout(() => uni.navigateBack(), 1500);
})
.catch(err => {
uni.showToast({ title: '审批通过失败', icon: 'none' });
@@ -118,17 +169,11 @@
}
});
},
/**
* 审批驳回
*/
handleReject() {
if (!this.currentTask?.taskId) {
uni.showToast({ title: '暂无审批任务', icon: 'none' });
return;
}
// 二次确认(可扩展:添加驳回理由输入框)
uni.showModal({
title: '确认驳回',
content: '是否确定驳回该审批?',
@@ -137,10 +182,7 @@
rejectFlowTask(this.currentTask.taskId)
.then(() => {
uni.showToast({ title: '审批驳回成功' });
// 成功后返回上一页(可根据需求调整)
setTimeout(() => {
uni.navigateBack();
}, 1500);
setTimeout(() => uni.navigateBack(), 1500);
})
.catch(err => {
uni.showToast({ title: '审批驳回失败', icon: 'none' });
@@ -151,14 +193,9 @@
});
}
},
onLoad(options) {
console.log('页面入参:', options);
// 校验入参避免undefined
this.bizId = options.bizId || '';
this.bizType = options.bizType || '';
// 入参缺失提示
if (!this.bizId || !this.bizType) {
uni.showToast({ title: '参数缺失,无法加载详情', icon: 'none' });
}
@@ -167,46 +204,60 @@
</script>
<style scoped>
/* 页面容器 */
.approval-detail-page {
min-height: 100vh;
padding-bottom: 120rpx; /* 给底部按钮栏留空间 */
padding: 24rpx 24rpx 140rpx;
background: #f7f8fa;
}
/* 底部审批按钮栏 */
.approval-btn-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
.flow-summary-card, .flow-history-card {
background: #fff;
border-radius: 24rpx;
padding: 24rpx;
margin-bottom: 24rpx;
box-shadow: 0 8rpx 30rpx rgba(0,0,0,0.04);
}
.summary-head {
display: flex;
padding: 20rpx;
background-color: #fff;
border-top: 1px solid #eee;
z-index: 99;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
}
/* 按钮通用样式 */
.btn {
flex: 1;
height: 88rpx;
line-height: 88rpx;
border-radius: 44rpx;
.summary-title, .card-title {
font-size: 32rpx;
border: none;
margin: 0 10rpx;
font-weight: 600;
color: #1f2937;
}
/* 驳回按钮样式 */
.reject-btn {
background-color: #fff;
color: #ff4757;
border: 1px solid #ff4757;
.status-tag {
padding: 8rpx 16rpx;
border-radius: 999rpx;
font-size: 24rpx;
background: #eef2ff;
color: #4f46e5;
}
/* 通过按钮样式 */
.approve-btn {
background-color: #007aff;
color: #fff;
.summary-grid {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
</style>
.summary-item {
width: calc(50% - 8rpx);
background: #f8fafc;
border-radius: 16rpx;
padding: 20rpx;
}
.label { display:block; color:#6b7280; font-size:24rpx; margin-bottom: 8rpx; }
.value { display:block; color:#111827; font-size:28rpx; font-weight:500; }
.timeline-item { position: relative; display:flex; padding-top: 24rpx; }
.timeline-dot { width: 18rpx; height:18rpx; border-radius:50%; background:#cbd5e1; margin-right: 16rpx; margin-top: 8rpx; flex-shrink:0; }
.timeline-dot.approve, .timeline-dot.approved, .timeline-dot.running { background:#22c55e; }
.timeline-dot.reject, .timeline-dot.rejected { background:#ef4444; }
.timeline-line { position:absolute; left: 8rpx; top: 36rpx; bottom:-4rpx; width:2rpx; background:#e5e7eb; }
.timeline-content { flex:1; padding-bottom: 8rpx; }
.timeline-header, .timeline-meta { display:flex; justify-content:space-between; gap: 16rpx; font-size:24rpx; color:#6b7280; }
.timeline-name { color:#111827; font-weight:600; }
.timeline-remark { margin-top: 8rpx; color:#374151; font-size:26rpx; }
.approval-btn-bar { position: fixed; bottom: 0; left: 0; right: 0; display: flex; padding: 20rpx; background-color: #fff; border-top: 1px solid #eee; z-index: 99; }
.btn { flex: 1; height: 88rpx; line-height: 88rpx; border-radius: 44rpx; font-size: 32rpx; border: none; margin: 0 10rpx; }
.reject-btn { background-color: #fff; color: #ff4757; border: 1px solid #ff4757; }
.approve-btn { background-color: #007aff; color: #fff; }
</style>

View File

@@ -42,7 +42,7 @@ export default {
}
},
methods: {
submitLeave(payload) { return addLeaveReq(payload) }
submitLeave(payload) { return addLeaveReq({ ...payload, status: 'pending', bizType: 'leave' }) }
}
}
</script>

View File

@@ -1,44 +1,142 @@
<template>
<request-form
title="报销申请"
subtitle="填写报销信息,支持手机端快速提交"
biz-type="reimburse"
:request-api="submitReimburse"
:initial-form="initialForm"
:sections="sections"
:flow-fields="flowFields"
/>
<view class="page">
<view class="card">
<view class="card-title">基础信息</view>
<view class="field">
<text class="label">报销类型<text class="req">*</text></text>
<picker mode="selector" :range="reimburseTypes" @change="onReimburseTypeChange">
<view class="input-like clickable">{{ reimburseType || '请选择报销类型' }}</view>
</picker>
</view>
<view class="field">
<text class="label">报销金额<text class="req">*</text></text>
<input class="input" type="text" :value="totalAmount" placeholder="请输入金额" @input="onAmountInput" />
</view>
<view class="field">
<text class="label">附件</text>
<view class="input-like clickable" @tap="openFilePicker">点击上传附件</view>
<view v-if="uploadedFiles.length" class="file-list">
<view v-for="(file, idx) in uploadedFiles" :key="file.ossId || file.url || idx" class="file-item">
<text class="file-name">{{ file.originalName || file.fileName || file.name || file.url }}</text>
<text class="remove" @tap.stop="removeFile(idx)">删除</text>
</view>
</view>
</view>
</view>
<view class="card">
<view class="card-title">说明</view>
<view class="field">
<text class="label">事由<text class="req">*</text></text>
<textarea v-model="reason" class="textarea" placeholder="请说明报销用途" />
</view>
<view class="field">
<text class="label">备注</text>
<textarea v-model="remark" class="textarea" placeholder="可选" />
</view>
</view>
<view class="card">
<button class="btn primary" :loading="submitting" @tap="submit">提交申请</button>
</view>
<x-file-picker ref="globalFilePicker" @select="onGlobalFileSelect" />
</view>
</template>
<script>
import RequestForm from '@/components/hrm/RequestForm.vue'
import { addReimburseReq } from '@/api/hrm/reimburse'
import { uploadFile } from '@/api/common/upload'
import XFilePicker from '@/components/x-native-uploader/x-file-picker.vue'
export default {
components: { RequestForm },
components: { XFilePicker },
data() {
return {
initialForm: { reimburseType: '', totalAmount: '', applyFileIds: '', reason: '', remark: '' },
sections: [
{ key: 'basic', title: '基础信息', fields: [
{ key: 'reimburseType', label: '报销类型', type: 'select', required: true, placeholder: '请选择报销类型', options: ['差旅报销', '招待报销', '采购报销', '办公报销', '其他'] },
{ key: 'totalAmount', label: '报销金额', type: 'input', inputType: 'digit', required: true, placeholder: '请输入金额' },
{ key: 'applyFileIds', label: '附件', type: 'file', required: false, placeholder: '上传附件文件' }
]},
{ key: 'desc', title: '说明', fields: [
{ key: 'reason', label: '事由', type: 'textarea', required: true, placeholder: '请说明报销用途' },
{ key: 'remark', label: '备注', type: 'textarea', required: false, placeholder: '可选' }
]}
],
flowFields: [
{ key: 'reimburseType', label: '报销类型', required: true },
{ key: 'totalAmount', label: '报销金额', required: true },
{ key: 'reason', label: '事由', required: true }
]
reimburseTypes: ['差旅报销', '招待报销', '采购报销', '办公报销', '其他'],
reimburseType: '',
totalAmount: '',
reason: '',
remark: '',
applyFileIds: '',
uploadedFiles: [],
submitting: false
}
},
methods: { submitReimburse(payload) { return addReimburseReq(payload) } }
methods: {
onReimburseTypeChange(e) {
var idx = Number(e.detail.value)
this.reimburseType = this.reimburseTypes[idx] || ''
},
onAmountInput(e) {
this.totalAmount = e.detail.value
},
openFilePicker() {
var picker = this.$refs.globalFilePicker
if (picker && picker.open) picker.open()
},
async onGlobalFileSelect(payload) {
var files = Array.isArray(payload) ? payload : [payload]
for (var i = 0; i < files.length; i++) {
var f = files[i]
if (!f) continue
try {
var uploaded = await uploadFile({
path: f.path || f.url || '',
name: f.name || f.fileName || 'file',
size: f.size || 0,
type: f.type || ''
})
this.uploadedFiles.push({
name: uploaded.fileName || f.name || f.fileName || 'file',
fileName: uploaded.fileName || f.fileName || f.name || '',
originalName: f.name || f.fileName || uploaded.fileName || '',
url: uploaded.url || f.path || f.url || '',
ossId: uploaded.ossId || uploaded.url || f.path || f.url || ''
})
} catch (e) {
uni.showToast({ title: e.message || '上传失败', icon: 'none' })
}
}
this.syncFileIds()
},
removeFile(idx) {
this.uploadedFiles.splice(idx, 1)
this.syncFileIds()
},
syncFileIds() {
this.applyFileIds = this.uploadedFiles.map(function (f) { return f.ossId }).join(',')
},
async submit() {
if (!this.reimburseType) return uni.showToast({ title: '请选择报销类型', icon: 'none' })
if (!this.totalAmount) return uni.showToast({ title: '请输入报销金额', icon: 'none' })
if (!this.reason) return uni.showToast({ title: '请填写事由', icon: 'none' })
this.submitting = true
try {
await addReimburseReq({
reimburseType: this.reimburseType,
totalAmount: this.totalAmount,
applyFileIds: this.applyFileIds,
reason: this.reason,
remark: this.remark,
status: 'pending',
bizType: 'reimburse'
})
uni.showToast({ title: '提交成功', icon: 'success' })
setTimeout(() => uni.navigateBack({ delta: 1 }), 500)
} catch (e) {
uni.showToast({ title: e.message || '提交失败', icon: 'none' })
} finally {
this.submitting = false
}
}
}
}
</script>
<style scoped></style>
<style scoped>
.page{min-height:100vh;background:#f5f7fb;padding:24rpx;box-sizing:border-box}.card{background:#fff;border-radius:20rpx;padding:24rpx;margin-bottom:20rpx}.field{margin-bottom:18rpx}.label{display:block;margin-bottom:8rpx;font-size:24rpx;color:#6b7280}.req{color:#ef4444;margin-left:4rpx}.input-like,.input,.textarea{width:100%;box-sizing:border-box;border:1rpx solid #e5e7eb;border-radius:16rpx;background:#f9fafb;padding:20rpx;font-size:28rpx;color:#111827}.input{min-height:88rpx}.clickable{color:#111827}.textarea{min-height:160rpx}.file-list{margin-top:16rpx;display:flex;flex-direction:column;gap:12rpx}.file-item{display:flex;align-items:center;justify-content:space-between;padding:18rpx 18rpx;border-radius:16rpx;background:#f8fafc;border:1rpx solid #e5eefc}.file-name{flex:1;font-size:26rpx;color:#334155;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-right:16rpx}.remove{font-size:24rpx;color:#ef4444;flex-shrink:0}.btn.primary{width:100%;background:#1677ff;color:#fff;border-radius:16rpx}
</style>

View File

@@ -22,7 +22,7 @@ export default {
sections: [
{ key: 'basic', title: '基础信息', fields: [
{ key: 'sealType', label: '用印类型', type: 'select', required: true, placeholder: '请选择用印类型', options: ['合同用印', '公章', '财务章', '法人章', '其他'] },
{ key: 'applyFileIds', label: '附件', type: 'file', required: true, placeholder: '上传盖章文件' }
{ key: 'applyFileIds', label: '附件', type: 'file', required: true, placeholder: '上传盖章文件', accept: '.pdf' }
]},
{ key: 'desc', title: '说明', fields: [
{ key: 'reason', label: '用途说明', type: 'textarea', required: true, placeholder: '请说明盖章用途与背景' },
@@ -36,7 +36,7 @@ export default {
]
}
},
methods: { submitSeal(payload) { return addSealReq(payload) } }
methods: { submitSeal(payload) { return addSealReq({ ...payload, status: 'pending', bizType: 'seal' }) } }
}
</script>

View File

@@ -1,48 +1,301 @@
<template>
<request-form
title="出差申请"
subtitle="填写出差信息,支持手机端快速提交"
biz-type="travel"
:request-api="submitTravel"
:initial-form="initialForm"
:sections="sections"
:flow-fields="flowFields"
/>
<view class="page">
<view class="card">
<view class="card-title">基础信息</view>
<view class="field">
<text class="label">出差类型<text class="req">*</text></text>
<picker mode="selector" :range="travelTypes" @change="onTravelTypeChange">
<view class="input-like clickable">{{ travelType || '请选择出差类型' }}</view>
</picker>
</view>
<view class="field">
<text class="label">开始时间<text class="req">*</text></text>
<view class="input-like clickable" @tap="openDateTime('startTime')">{{ formatDateTime(startTime) || '请选择开始时间' }}</view>
</view>
<view class="field">
<text class="label">结束时间<text class="req">*</text></text>
<view class="input-like clickable" @tap="openDateTime('endTime')">{{ formatDateTime(endTime) || '请选择结束时间' }}</view>
</view>
<view class="field">
<text class="label">目的地<text class="req">*</text></text>
<view class="input-like clickable" @tap="openCityPicker">{{ destination || '请选择城市' }}</view>
</view>
<view class="field">
<text class="label">预估费用</text>
<input class="input" type="text" :value="estimatedCost" placeholder="请输入预估费用" @input="onEstimatedCostInput" />
</view>
</view>
<view class="card">
<view class="card-title">审批信息</view>
<view class="field">
<text class="label">审批人<text class="req">*</text></text>
<view class="input-like clickable" @tap="openEmployeePicker('assignee')">{{ employeeLoading ? '审批人加载中...' : (assigneeLabel || '请选择审批人') }}</view>
</view>
<view class="field">
<text class="label">抄送人</text>
<view class="input-like clickable" @tap="openEmployeePicker('cc')">{{ employeeLoading ? '抄送人加载中...' : ccLabel }}</view>
</view>
</view>
<view class="card">
<view class="card-title">说明</view>
<view class="field">
<text class="label">事由<text class="req">*</text></text>
<textarea v-model="reason" class="textarea" placeholder="请说明出差目的与任务" />
</view>
<view class="field">
<text class="label">附件</text>
<view class="input-like clickable" @tap="openFilePicker">点击上传附件文件</view>
<view v-if="uploadedFiles.length" class="file-list">
<view v-for="(file, idx) in uploadedFiles" :key="file.ossId || file.url || idx" class="file-item">
<text class="file-name">{{ file.originalName || file.fileName || file.name || file.url }}</text>
<text class="remove" @tap.stop="removeFile(idx)">删除</text>
</view>
</view>
</view>
<view class="field">
<text class="label">备注</text>
<textarea v-model="remark" class="textarea" placeholder="可选" />
</view>
</view>
<view class="card">
<button class="btn primary" :loading="submitting" @tap="submit">提交申请</button>
</view>
<x-file-picker ref="globalFilePicker" @select="onGlobalFileSelect" />
<employee-picker
:visible.sync="employeeSheetVisible"
:title="employeeMode === 'assignee' ? '选择审批人' : '选择抄送人'"
:list="employees"
:multiple="employeeMode === 'cc'"
:value="employeeMode === 'assignee' ? assignee : ccUsers"
@change="onEmployeeChange"
/>
<date-time-picker :visible.sync="dateTimeSheetVisible" :value="dateValue" @change="onDateChange" />
<city-picker :visible.sync="citySheetVisible" :value="destination" @change="onCityChange" />
</view>
</template>
<script>
import RequestForm from '@/components/hrm/RequestForm.vue'
import { addTravelReq } from '@/api/hrm/travel'
import { listUser } from '@/api/oa/user'
import { getEmployeeByUserId } from '@/api/hrm/employee'
import { uploadFile } from '@/api/common/upload'
import EmployeePicker from '@/components/hrm/EmployeePicker.vue'
import DateTimePicker from '@/components/hrm/DateTimePicker.vue'
import CityPicker from '@/components/hrm/CityPicker.vue'
import XFilePicker from '@/components/x-native-uploader/x-file-picker.vue'
export default {
components: { RequestForm },
components: { EmployeePicker, DateTimePicker, CityPicker, XFilePicker },
data() {
return {
initialForm: { travelType: '', startTime: '', endTime: '', destination: '', reason: '', applyFileIds: '', remark: '' },
sections: [
{ key: 'basic', title: '基础信息', fields: [
{ key: 'travelType', label: '出差类型', type: 'select', required: true, placeholder: '请选择出差类型', options: ['客户拜访', '项目支持', '培训学习', '会议会展', '验收交付', '其他'] },
{ key: 'startTime', label: '开始时间', type: 'datetime', required: true, placeholder: '请选择开始时间' },
{ key: 'endTime', label: '结束时间', type: 'datetime', required: true, placeholder: '请选择结束时间' },
{ key: 'destination', label: '目的地', type: 'city', required: true, placeholder: '请选择全球城市' }
]},
{ key: 'desc', title: '说明', fields: [
{ key: 'reason', label: '事由', type: 'textarea', required: true, placeholder: '请说明出差目的与任务' },
{ key: 'applyFileIds', label: '附件', type: 'file', required: false, placeholder: '上传附件文件' },
{ key: 'remark', label: '备注', type: 'textarea', required: false, placeholder: '可选' }
]}
],
flowFields: [
{ key: 'travelType', label: '出差类型', required: true },
{ key: 'startTime', label: '开始时间', required: true },
{ key: 'endTime', label: '结束时间', required: true },
{ key: 'destination', label: '目的地', required: true },
{ key: 'reason', label: '事由', required: true }
]
travelTypes: ['客户拜访', '项目支持', '培训学习', '会议会展', '验收交付', '其他'],
travelType: '',
startTime: '',
endTime: '',
destination: '',
estimatedCost: '',
reason: '',
remark: '',
accessoryApplyIds: '',
uploadedFiles: [],
submitting: false,
employees: [],
employeeLoading: true,
employeeSheetVisible: false,
employeeMode: 'assignee',
assignee: null,
ccUsers: [],
dateTimeSheetVisible: false,
dateFieldKey: '',
dateValue: '',
citySheetVisible: false,
cachedAssigneeKey: '',
currentEmpId: null
}
},
methods: { submitTravel(payload) { return addTravelReq(payload) } }
computed: {
assigneeLabel() {
return this.assignee ? this.employeeName(this.assignee) : ''
},
ccLabel() {
return this.ccUsers.length ? '已选择 ' + this.ccUsers.length + ' 人' : '点击选择抄送人'
}
},
async created() {
this.cachedAssigneeKey = 'hrm_manual_assignee_' + 'travel'
await this.loadEmployees()
await this.loadCurrentEmpId()
this.restoreAssignee()
},
methods: {
employeeName(emp) {
return emp && (emp.empName || emp.nickName || emp.userName || emp.realName) || ('员工' + ((emp && (emp.userId || emp.id)) || ''))
},
employeeDept(emp) {
return (emp && emp.dept && emp.dept.deptName) || (emp && emp.deptName) || '未分配部门'
},
async loadEmployees() {
this.employeeLoading = true
try {
var res = await listUser({ pageNum: 1, pageSize: 500 })
this.employees = res.rows || res.data || []
} catch (e) {
this.employees = []
} finally {
this.employeeLoading = false
}
},
async loadCurrentEmpId() {
try {
var oaId = uni.getStorageSync('oaId')
if (!oaId) return
var emp = await getEmployeeByUserId(oaId)
if (emp && emp.data && emp.data.empId) {
this.currentEmpId = emp.data.empId
}
} catch (e) {}
},
restoreAssignee() {
try {
var raw = uni.getStorageSync(this.cachedAssigneeKey)
if (!raw) return
var cached = typeof raw === 'string' ? JSON.parse(raw) : raw
var id = String(cached.userId || cached.empId || '')
var hit = this.employees.find(function (e) { return String(e.userId || e.id) === id })
if (hit) this.assignee = hit
} catch (e) {}
},
openEmployeePicker(mode) {
if (this.employeeLoading) {
uni.showToast({ title: '人员加载中,请稍候', icon: 'none' })
return
}
this.employeeMode = mode
this.employeeSheetVisible = true
},
onEmployeeChange(val) {
if (this.employeeMode === 'assignee') {
this.assignee = val
try {
uni.setStorageSync(this.cachedAssigneeKey, JSON.stringify({
userId: val && (val.userId || val.id),
empId: val && val.empId || '',
empName: this.employeeName(val),
deptName: this.employeeDept(val)
}))
} catch (e) {}
} else {
this.ccUsers = val || []
}
},
onTravelTypeChange(e) {
var idx = Number(e.detail.value)
this.travelType = this.travelTypes[idx] || ''
},
openDateTime(fieldKey) {
this.dateFieldKey = fieldKey
this.dateValue = this[fieldKey] || ''
this.dateTimeSheetVisible = true
},
onDateChange(val) {
if (!this.dateFieldKey) return
this[this.dateFieldKey] = val
},
formatDateTime(v) {
if (!v) return ''
return String(v).replace('T', ' ').substring(0, 16)
},
openCityPicker() {
this.citySheetVisible = true
},
onCityChange(val) {
this.destination = val || ''
},
onEstimatedCostInput(e) {
this.estimatedCost = e.detail.value
},
openFilePicker() {
var picker = this.$refs.globalFilePicker
if (picker && picker.open) picker.open()
},
async onGlobalFileSelect(payload) {
var files = Array.isArray(payload) ? payload : [payload]
for (var i = 0; i < files.length; i++) {
var f = files[i]
if (!f) continue
try {
var uploaded = await uploadFile({ path: f.path || f.url || '', name: f.name || f.fileName || 'file', size: f.size || 0, type: f.type || '' })
this.uploadedFiles.push({
name: uploaded.fileName || f.name || f.fileName || 'file',
fileName: uploaded.fileName || f.fileName || f.name || '',
originalName: f.name || f.fileName || uploaded.fileName || '',
url: uploaded.url || f.path || f.url || '',
ossId: uploaded.ossId || uploaded.url || f.path || f.url || ''
})
} catch (e) {
uni.showToast({ title: e.message || '上传失败', icon: 'none' })
}
}
this.syncFileIds()
},
removeFile(idx) {
this.uploadedFiles.splice(idx, 1)
this.syncFileIds()
},
syncFileIds() {
this.accessoryApplyIds = this.uploadedFiles.map(function (f) { return f.ossId }).join(',')
},
validate() {
if (!this.assignee) return uni.showToast({ title: '请选择审批人', icon: 'none' }), false
if (!this.travelType) return uni.showToast({ title: '请选择出差类型', icon: 'none' }), false
if (!this.startTime) return uni.showToast({ title: '请选择开始时间', icon: 'none' }), false
if (!this.endTime) return uni.showToast({ title: '请选择结束时间', icon: 'none' }), false
if (!this.destination) return uni.showToast({ title: '请选择目的地', icon: 'none' }), false
if (!this.reason) return uni.showToast({ title: '请填写事由', icon: 'none' }), false
return true
},
async submit() {
if (!this.validate()) return
this.submitting = true
try {
await addTravelReq({
empId: this.currentEmpId,
travelType: this.travelType,
startTime: this.startTime,
endTime: this.endTime,
destination: this.destination,
estimatedCost: this.estimatedCost,
reason: this.reason,
accessoryApplyIds: this.accessoryApplyIds,
remark: this.remark,
status: 'pending',
bizType: 'travel',
manualAssigneeUserId: this.assignee && (this.assignee.userId || this.assignee.id),
ccUserIds: this.ccUsers.map(function (u) { return u.userId || u.id }).join(',')
})
uni.showToast({ title: '提交成功', icon: 'success' })
setTimeout(() => uni.navigateBack({ delta: 1 }), 500)
} catch (e) {
uni.showToast({ title: e.message || '提交失败', icon: 'none' })
} finally {
this.submitting = false
}
}
}
}
</script>
<style scoped></style>
<style scoped>
.page{min-height:100vh;background:#f5f7fb;padding:24rpx;box-sizing:border-box}.card{background:#fff;border-radius:20rpx;padding:24rpx;margin-bottom:20rpx}.card-title{font-size:30rpx;font-weight:700;color:#111827;margin-bottom:16rpx}.field{margin-bottom:18rpx}.label{display:block;margin-bottom:8rpx;font-size:24rpx;color:#6b7280}.req{color:#ef4444;margin-left:4rpx}.input-like,.input,.textarea{width:100%;box-sizing:border-box;border:1rpx solid #e5e7eb;border-radius:16rpx;background:#f9fafb;padding:20rpx;font-size:28rpx;color:#111827}.input{min-height:88rpx}.clickable{color:#111827}.textarea{min-height:160rpx}.file-list{margin-top:16rpx;display:flex;flex-direction:column;gap:12rpx}.file-item{display:flex;align-items:center;justify-content:space-between;padding:18rpx 18rpx;border-radius:16rpx;background:#f8fafc;border:1rpx solid #e5eefc}.file-name{flex:1;font-size:26rpx;color:#334155;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-right:16rpx}.remove{font-size:24rpx;color:#ef4444;flex-shrink:0}.btn.primary{width:100%;background:#1677ff;color:#fff;border-radius:16rpx}
</style>