Files
im-uniapp/components/hrm/RequestForm.vue

523 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="page">
<view class="scroll">
<view
v-for="section in sections"
:key="section.key"
class="card"
>
<view class="card-title">{{ section.title }}</view>
<view
v-for="field in section.fields"
:key="field.key"
class="field"
>
<text class="label">
{{ field.label }}
<text v-if="field.required" class="req">*</text>
</text>
<template v-if="field.type === 'select'">
<picker
mode="selector"
:range="field.options"
@change="onSelect(field, $event)"
>
<view class="input-like clickable">
{{ getFieldDisplayValue(field) }}
</view>
</picker>
</template>
<template v-else-if="field.type === 'datetime'">
<view class="input-like clickable" @tap="openDateTime(field.key)">
{{ getFieldDisplayValue(field) }}
</view>
</template>
<template v-else-if="field.type === 'city'">
<view class="input-like clickable" @tap="openCity(field.key)">
{{ getFieldDisplayValue(field) || field.placeholder || '选择城市' }}
</view>
</template>
<template v-else-if="field.type === 'computed'">
<view class="computed-box">
{{ getFieldDisplayValue(field) }}
</view>
</template>
<template v-else-if="field.type === 'sealPreview'">
<view class="seal-preview-box" v-if="getSealPreviewSrc(field)">
<image class="seal-preview-image" :src="getSealPreviewSrc(field)" mode="aspectFit"></image>
<view class="seal-preview-text">盖章示例当前选择</view>
</view>
<view class="input-like" v-else>请先选择用印类型</view>
</template>
<template v-else-if="field.type === 'file'">
<view class="file-picker-wrap">
<view class="upload-box clickable" :class="{ disabled: uploadingFieldKey === field.key }" @tap="openFilePicker(field.key)">
<view class="upload-hint">{{ field.placeholder || '点击上传文件' }}</view>
<view class="upload-sub">{{ uploadingFieldKey === field.key ? '上传中,请稍候...' : (field.uploadTip || '支持文件上传,点击后选择文件') }}</view>
</view>
<view v-if="field.accept" class="upload-rule">
仅支持 {{ field.accept }} 格式文件
</view>
<view v-if="uploadingFieldKey === field.key" class="upload-loading">
<text class="loading-dot"></text>
<text class="loading-text">文件正在上传中...</text>
</view>
<view
v-if="getFileList(field.key).length"
class="file-list"
>
<view
v-for="(file, idx) in getFileList(field.key)"
:key="file.ossId || file.url || idx"
class="file-item"
>
<text class="file-name">{{ file.originalName || file.fileName || file.url }}</text>
<text class="remove" @tap="removeFile(field.key, idx)">删除</text>
</view>
</view>
</view>
</template>
<template v-else-if="field.type === 'textarea'">
<textarea
v-model="form[field.key]"
class="textarea"
:maxlength="field.maxlength || 200"
:placeholder="field.placeholder"
/>
</template>
<template v-else-if="field.type === 'input' || field.type === 'number' || field.type === 'text' || !field.type">
<view class="text-input-wrap">
<input
v-model="form[field.key]"
class="input"
:type="normalizeInputType(field.inputType)"
:placeholder="field.placeholder"
:confirm-type="field.confirmType || 'done'"
:adjust-position="true"
@focus="onInputFocus(field.key)"
/>
</view>
</template>
<template v-else>
<input
:value="form[field.key]"
class="input"
type="text"
:placeholder="field.placeholder"
:confirm-type="field.confirmType || 'done'"
:adjust-position="true"
@input="onInput(field.key, $event)"
@focus="onInputFocus(field.key)"
/>
</template>
</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')">
{{ assigneeLabel || '点击选择审批人' }}
</view>
</view>
</view>
<view class="card">
<view class="card-title">抄送</view>
<view class="field">
<text class="label">抄送人</text>
<view class="input-like clickable" @tap="openEmployeePicker('cc')">
{{ ccLabel }}
</view>
</view>
</view>
<view class="card">
<view class="card-title">提交</view>
<button class="btn primary" :loading="submitting" @tap="submit">提交申请</button>
</view>
</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="form[datetimeFieldKey]"
@change="onDateTimeChange"
/>
<city-picker
:visible.sync="citySheetVisible"
:value="form[cityFieldKey]"
@change="onCityChange"
/>
</view>
</template>
<script>
import { ccFlowTask } from '@/api/hrm/flow'
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: { EmployeePicker, DateTimePicker, CityPicker, XFilePicker },
props: {
title: String,
subtitle: String,
bizType: String,
requestApi: Function,
initialForm: Object,
sections: Array,
flowFields: { type: Array, default: () => [] }
},
data() {
return {
submitting: false,
form: JSON.parse(JSON.stringify(this.initialForm || {})),
employees: [],
employeeSheetVisible: false,
employeeMode: 'assignee',
assignee: null,
ccUsers: [],
cachedAssigneeKey: '',
dateTimeSheetVisible: false,
datetimeFieldKey: '',
citySheetVisible: false,
cityFieldKey: '',
fileFields: {},
currentFileFieldKey: '',
currentFileFieldConfig: null,
uploadingFieldKey: '',
currentInputFieldKey: '',
currentEmpId: null
}
},
computed: {
assigneeLabel() {
return this.assignee ? this.employeeName(this.assignee) : ''
},
ccLabel() {
return this.ccUsers.length ? '已选择 ' + this.ccUsers.length + ' 人' : '点击选择抄送人'
}
},
async created() {
this.cachedAssigneeKey = 'hrm_manual_assignee_' + (this.bizType || 'default')
await this.loadEmployees()
await this.loadCurrentEmpId()
this.restoreAssignee()
},
watch: {
'form.startTime'() {
this.autoFillHours()
},
'form.endTime'() {
this.autoFillHours()
}
},
methods: {
getFieldValue(fieldKey) {
return this.form[fieldKey]
},
getFieldDisplayValue(field) {
if (field.type === 'datetime') return this.formatDateTime(field.key) || field.placeholder || ''
if (field.type === 'computed') return this.computedFieldText(field.key) || field.placeholder || '—'
var value = this.getFieldValue(field.key)
return value !== undefined && value !== null && value !== '' ? value : (field.placeholder || '')
},
getFileList(fieldKey) {
return this.fileFields[fieldKey] || []
},
normalizeInputType(inputType) {
return inputType || 'text'
},
getSealPreviewSrc(field) {
if (!field || field.key !== 'sealType') return ''
const value = this.form[field.key]
const map = {
'山东福安德信息科技有限公司采购部专用章FAD201400201.png': 'http://49.232.154.205:10900/fad-oa/files%2Fstamp%2F山东福安德信息科技有限公司采购部专用章FAD201400201.png',
'山东福安德信息科技有限公司采购部专用章FAD201400202.png': 'http://49.232.154.205:10900/fad-oa/files%2Fstamp%2F山东福安德信息科技有限公司采购部专用章FAD201400202.png',
'山东福安德信息科技有限公司采购部专用章FAD201400401.png': 'http://49.232.154.205:10900/fad-oa/files%2Fstamp%2F山东福安德信息科技有限公司采购部专用章FAD201400401.png'
}
return map[value] || ''
},
onInputFocus(fieldKey) {
this.currentInputFieldKey = fieldKey
},
onInput(fieldKey, e) {
var value = e && e.detail ? e.detail.value : e
this.$set(this.form, fieldKey, value)
},
onSelect(field, e) {
var idx = Number(e.detail.value)
var options = field.options || []
this.$set(this.form, field.key, options[idx])
},
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() {
try {
var res = await listUser({ pageNum: 1, pageSize: 500 })
this.employees = res.rows || res.data || []
} catch (e) {
this.employees = []
}
},
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) {
console.log('[loadCurrentEmpId] error', 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) {
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 || []
}
},
openFilePicker(fieldKey) {
this.currentFileFieldKey = fieldKey
this.currentFileFieldConfig = this.flowFields.find(function (f) { return f.key === fieldKey }) || null
var picker = this.$refs.globalFilePicker
if (picker && picker.open) {
picker.open()
} else {
console.warn('[RequestForm] file picker ref missing or open() unavailable', fieldKey)
}
},
async onGlobalFileSelect(payload) {
var fieldKey = this.currentFileFieldKey
if (!fieldKey) return
var files = Array.isArray(payload) ? payload : [payload]
if (!this.fileFields[fieldKey]) this.$set(this.fileFields, fieldKey, [])
var cfg = this.currentFileFieldConfig || {}
var allowedExt = (cfg.accept || '').toLowerCase().split(',').map(function (x) { return x.trim() }).filter(Boolean)
if (cfg.key === 'applyFileIds' && this.bizType === 'seal' && allowedExt.indexOf('.pdf') === -1) {
allowedExt = ['.pdf']
}
this.uploadingFieldKey = fieldKey
uni.showLoading({ title: '上传中...' })
try {
for (var i = 0; i < files.length; i++) {
var f = files[i]
if (!f) continue
var name = String(f.name || f.fileName || '').toLowerCase()
var ext = name.indexOf('.') >= 0 ? '.' + name.split('.').pop() : ''
if (allowedExt.length && allowedExt.indexOf(ext) === -1) {
uni.showToast({ title: '仅支持上传 PDF 文件', icon: 'none' })
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.fileFields[fieldKey].push({
name: uploaded.fileName || f.name || f.fileName || 'file',
extname: ext.replace('.', '') || (f.name || f.fileName || '').split('.').pop() || '',
url: uploaded.url || f.path || f.url || '',
ossId: uploaded.ossId || uploaded.url || f.path || f.url || '',
fileName: uploaded.fileName || f.fileName || f.name || '',
originalName: f.name || f.fileName || uploaded.fileName || ''
})
} catch (e) {
uni.showToast({ title: e.message || '上传失败', icon: 'none' })
}
}
this.syncFileIds(fieldKey)
} finally {
uni.hideLoading()
this.uploadingFieldKey = ''
this.currentFileFieldKey = ''
this.currentFileFieldConfig = null
}
},
removeFile(fieldKey, idx) {
var arr = (this.fileFields[fieldKey] || []).slice()
arr.splice(idx, 1)
this.$set(this.fileFields, fieldKey, arr)
this.syncFileIds(fieldKey)
},
syncFileIds(fieldKey) {
var arr = this.fileFields[fieldKey] || []
this.$set(this.form, fieldKey, arr.map(function (x) { return x.ossId }).join(','))
},
openDateTime(fieldKey) {
this.datetimeFieldKey = fieldKey
this.dateTimeSheetVisible = true
},
formatDateTime(key) {
var v = this.form[key]
if (!v) return ''
return String(v).replace('T', ' ').substring(0, 16)
},
onDateTimeChange(val) {
if (!this.datetimeFieldKey) return
this.$set(this.form, this.datetimeFieldKey, val)
this.autoFillHours()
},
computedFieldText(key) {
if (key === 'hours') {
var val = this.form[key]
return val ? val + ' 小时' : ''
}
var value = this.form[key]
return value !== undefined && value !== null ? value : ''
},
openCity(fieldKey) {
this.cityFieldKey = fieldKey
this.citySheetVisible = true
},
onCityChange(val) {
if (!this.cityFieldKey) return
this.$set(this.form, this.cityFieldKey, val)
},
autoFillHours() {
var s = this.parseDT(this.form.startTime)
var e = this.parseDT(this.form.endTime)
if (!s || !e) return
var ms = e.getTime() - s.getTime()
if (ms <= 0) return
var hours = (Math.round((ms / 3600000) * 2) / 2).toFixed(1).replace(/\.0$/, '')
this.$set(this.form, 'hours', hours)
},
parseDT(v) {
if (!v) return null
var d = new Date(String(v).replace('T', ' ').replace(/-/g, '/'))
return Number.isNaN(d.getTime()) ? null : d
},
validateFlowFields() {
for (var i = 0; i < this.flowFields.length; i++) {
var f = this.flowFields[i]
if (f.required && !this.form[f.key]) {
uni.showToast({ title: '请填写' + f.label, icon: 'none' })
return false
}
}
if (!this.assignee) {
uni.showToast({ title: '请选择审批人', icon: 'none' })
return false
}
return true
},
submit() {
this.autoFillHours()
if (!this.validateFlowFields()) return
this.doSubmit()
},
buildSubmitPayload() {
return {
...this.form,
status: 'pending',
bizType: this.bizType,
manualAssigneeUserId: this.assignee.userId || this.assignee.id,
tplId: this.form.tplId || null,
empId: this.currentEmpId
}
},
async doSubmit() {
this.submitting = true
try {
var payload = this.buildSubmitPayload()
console.log('[RequestForm] submit payload', payload)
var res = await this.requestApi(payload)
var inst = res && (res.data || res)
if (!inst || (!inst.instId && !inst.bizId)) {
console.warn('[RequestForm] submit success but no flow instance returned', res)
}
if (this.ccUsers.length && inst && inst.instId) {
await ccFlowTask({
instId: inst.instId,
bizId: inst.bizId,
bizType: this.bizType,
ccUserIds: this.ccUsers.map(function (u) { return u.userId || u.id }),
remark: '手机端抄送',
fromUserId: (this.$store && this.$store.state && this.$store.state.user && this.$store.state.user.id) || '',
nodeId: 0,
nodeName: '节点#0',
readFlag: 0
})
}
uni.showToast({ title: '提交成功', icon: 'success' })
setTimeout(function () {
uni.navigateBack({ delta: 1 })
}, 500)
} catch (e) {
uni.showToast({ title: e.message || '提交失败', icon: 'none' })
} finally {
this.submitting = false
}
}
}
}
</script>
<style scoped>
.page{min-height:100vh;background:#f5f7fb}.scroll{height:100vh;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}.text-input-wrap{position:relative;z-index:20;background:transparent;pointer-events:auto}.text-input-wrap .input{position:relative;z-index:21;pointer-events:auto;touch-action:manipulation}.clickable{color:#111827}.textarea{min-height:160rpx}.computed-box{width:100%;box-sizing:border-box;padding:18rpx 20rpx;border-radius:16rpx;background:#eef6ff;color:#6b8fd6;font-size:26rpx;font-weight:500;line-height:1.5}.upload-box{position:relative;z-index:1;border:1rpx dashed #cbd5e1;border-radius:18rpx;background:linear-gradient(180deg,#fbfdff 0%,#f4f8ff 100%);padding:24rpx 22rpx;box-shadow:0 8rpx 24rpx rgba(22,119,255,.06);pointer-events:auto}.upload-hint{font-size:28rpx;color:#111827;font-weight:700}.upload-sub{margin-top:8rpx;font-size:22rpx;color:#94a3b8;line-height:1.5}.upload-rule{margin-top:8rpx;font-size:22rpx;color:#f59e0b;line-height:1.5}.upload-loading{margin-top:12rpx;display:flex;align-items:center;gap:12rpx;padding:16rpx 18rpx;border-radius:16rpx;background:#f0f7ff;color:#1677ff;font-size:24rpx;font-weight:500}.loading-dot{width:16rpx;height:16rpx;border-radius:50%;background:#1677ff;box-shadow:0 0 0 0 rgba(22,119,255,.45);animation:uploadPulse 1.2s infinite}.loading-text{flex:1}.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}@keyframes uploadPulse{0%{transform:scale(.85);opacity:.7}50%{transform:scale(1);opacity:1}100%{transform:scale(.85);opacity:.7}}
</style>