Files
im-uniapp/components/hrm/RequestForm.vue
2026-04-17 12:09:43 +08:00

166 lines
13 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">
<scroll-view class="scroll" scroll-y>
<view class="card" v-for="section in sections" :key="section.key">
<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="e => onSelect(field.key, field.options, e)"><view class="input-like clickable">{{ form[field.key] || field.placeholder }}</view></picker></template>
<template v-else-if="field.type === 'datetime'"><view class="input-like clickable" @tap="openDateTime(field.key)">{{ formatDateTime(field.key) || field.placeholder }}</view></template>
<template v-else-if="field.type === 'city'"><view class="input-like clickable" @tap="openCity(field.key)">{{ form[field.key] || field.placeholder }}</view></template>
<template v-else-if="field.type === 'computed'"><view class="computed-box">{{ computedFieldText(field.key) || field.placeholder || '' }}</view></template>
<template v-else-if="field.type === 'file'">
<view class="file-picker-wrap">
<view class="upload-box clickable" @tap="openFilePicker(field.key)">
<view class="upload-hint">{{ field.placeholder || '点击上传文件' }}</view>
<view class="upload-sub">支持文件上传点击后选择文件</view>
</view>
<view class="file-list" v-if="fileFields[field.key] && fileFields[field.key].length">
<view class="file-item" v-for="(file, idx) in fileFields[field.key]" :key="file.ossId || file.url || idx">
<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 class="textarea" v-model="form[field.key]" :maxlength="field.maxlength || 200" :placeholder="field.placeholder" /></template>
<template v-else><input class="input" :type="field.inputType || 'text'" v-model="form[field.key]" :placeholder="field.placeholder" /></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')">{{ ccUsers.length ? `已选择 ${ccUsers.length} ` : '点击选择抄送人' }}</view></view></view>
<view class="card"><view class="card-title">提交</view><button class="btn primary" :loading="submitting" @tap="submit">提交申请</button></view>
</scroll-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: '', imageFields: {}, fileFields: {}, currentEmpId: null } },
computed: { assigneeLabel() { return this.assignee ? this.employeeName(this.assignee) : '' } },
async created() {
this.cachedAssigneeKey = `hrm_manual_assignee_${this.bizType || 'default'}`;
await this.loadEmployees();
await this.loadCurrentEmpId()
this.restoreAssignee()
},
watch: {
'form.startTime'(val) { this.autoFillHours() },
'form.endTime'(val) { this.autoFillHours() }
},
methods: {
onSelect(fieldKey, options, e) {
const idx = Number(e.detail.value)
this.$set(this.form, fieldKey, options[idx])
},
employeeName(emp) { return emp?.empName || emp?.nickName || emp?.userName || emp?.realName || `员工${emp?.userId || emp?.id || ''}` },
employeeDept(emp) { return emp?.dept?.deptName || emp?.deptName || '未分配部门' },
async loadEmployees() { try { const res = await listUser({ pageNum: 1, pageSize: 500 }); this.employees = res.rows || res.data || [] } catch (e) { this.employees = [] } },
async loadCurrentEmpId() { try { const oaId = uni.getStorageSync('oaId'); if (!oaId) return; const emp = await getEmployeeByUserId(oaId); if (emp?.data?.empId) this.currentEmpId = emp.data.empId } catch (e) { console.log('[loadCurrentEmpId] error', e) } },
restoreAssignee() { try { const raw = uni.getStorageSync(this.cachedAssigneeKey); if (!raw) return; const cached = typeof raw === 'string' ? JSON.parse(raw) : raw; const id = String(cached.userId || cached.empId || ''); const hit = this.employees.find(e => 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.userId || val.id, empId: val.empId || '', empName: this.employeeName(val), deptName: this.employeeDept(val) })) } catch (e) {} } else { this.ccUsers = val || [] } },
openFilePicker(fieldKey) {
this.currentFileFieldKey = fieldKey
console.log('[RequestForm] openFilePicker', fieldKey, this.$refs.globalFilePicker)
const picker = this.$refs.globalFilePicker
if (picker && picker.open) {
picker.open()
} else {
console.warn('[RequestForm] file picker ref missing or open() unavailable,', fieldKey, picker)
}
},
async onGlobalFileSelect(payload) {
const fieldKey = this.currentFileFieldKey
console.log('[RequestForm] file select', fieldKey, payload)
if (!fieldKey) return
const files = Array.isArray(payload) ? payload : [payload]
if (!this.fileFields[fieldKey]) this.$set(this.fileFields, fieldKey, [])
for (const f of files.filter(Boolean)) {
try {
const 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: (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.$set(this.form, fieldKey, this.fileFields[fieldKey].map(x => x.ossId).join(','))
this.currentFileFieldKey = ''
},
removeFile(fieldKey, idx) { const arr = (this.fileFields[fieldKey] || []).slice(); arr.splice(idx, 1); this.$set(this.fileFields, fieldKey, arr); this.$set(this.form, fieldKey, arr.map(x => x.ossId).join(',')) },
openDateTime(fieldKey) { this.datetimeFieldKey = fieldKey; this.dateTimeSheetVisible = true },
formatDateTime(key) { const v = this.form[key]; if (!v) return ''; return 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') { const val = this.form[key]; return val ? `${val} 小时` : '' } return this.form[key] || '' },
openCity(fieldKey) { this.cityFieldKey = fieldKey; this.citySheetVisible = true },
onCityChange(val) { if (!this.cityFieldKey) return; this.$set(this.form, this.cityFieldKey, val) },
onFileSelect(fieldKey) { if (!this.fileFields[fieldKey]) this.$set(this.fileFields, fieldKey, []) },
onFileSuccess(fieldKey, e) {
if (!this.fileFields[fieldKey]) this.$set(this.fileFields, fieldKey, [])
const files = e?.tempFiles || []
const current = Array.isArray(this.fileFields[fieldKey]) ? this.fileFields[fieldKey] : []
this.fileFields[fieldKey] = [...current, ...files.map(f => ({
name: f.name || f.fileName || 'file',
extname: (f.name || f.fileName || '').split('.').pop() || '',
url: f.url || f.tempFilePath || f.path || '',
ossId: f.ossId || f.fileID || f.url || '',
fileName: f.fileName || f.name || '',
originalName: f.originalName || f.name || f.fileName || ''
}))]
this.$set(this.form, fieldKey, this.fileFields[fieldKey].map(x => x.ossId).join(','))
},
onFileDelete(fieldKey, e) {
const fileList = Array.isArray(this.fileFields[fieldKey]) ? this.fileFields[fieldKey] : []
const index = e?.index ?? e?.indexs?.[0] ?? -1
if (index >= 0) fileList.splice(index, 1)
this.$set(this.fileFields, fieldKey, [...fileList])
this.$set(this.form, fieldKey, fileList.map(x => x.ossId).join(','))
},
pickImages() {},
autoFillHours() {
const s = this.parseDT(this.form.startTime), e = this.parseDT(this.form.endTime)
if (!s || !e) return;
const ms = e.getTime() - s.getTime();
if (ms <= 0) return;
const hours = (Math.round((ms / 3600000) * 2) / 2).toFixed(1).replace(/\.0$/, '');
this.$set(this.form, 'hours', hours)
},
parseDT(v) { if (!v) return null; const d = new Date(String(v).replace('T', ' ').replace(/-/g, '/')); return Number.isNaN(d.getTime()) ? null : d },
submit() { this.autoFillHours(); for (const f of this.flowFields) if (f.required && !this.form[f.key]) return uni.showToast({ title: `请填写${f.label}`, icon: 'none' }); if (!this.assignee) return uni.showToast({ title: '请选择审批人', icon: 'none' }); this.doSubmit() },
async doSubmit() { this.submitting = true; try { const payload = { ...this.form, status: 'pending', empId: this.currentEmpId, manualAssigneeUserId: this.assignee.userId || this.assignee.id }; const res = await this.requestApi(payload); const inst = res?.data || res; if (this.ccUsers.length && inst?.instId) await ccFlowTask({ instId: inst.instId, bizId: inst.bizId, bizType: this.bizType, ccUserIds: this.ccUsers.map(u => u.userId || u.id), remark: '手机端抄送', fromUserId: this.$store?.state?.user?.id || '', nodeId: 0, nodeName: '节点#0', readFlag: 0 }); 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>
.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}.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{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);}.upload-hint{font-size:28rpx;color:#111827;font-weight:700}.upload-sub{margin-top:8rpx;font-size:22rpx;color:#94a3b8;line-height:1.5}.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>