增强办公
This commit is contained in:
57
components/hrm/CityPicker.vue
Normal file
57
components/hrm/CityPicker.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<view v-if="visible" class="mask" @tap="close">
|
||||
<view class="sheet" @tap.stop>
|
||||
<view class="sheet-header">
|
||||
<text class="sheet-title">选择目的地</text>
|
||||
<text class="sheet-close" @tap="close">关闭</text>
|
||||
</view>
|
||||
<view class="city-path">{{ pathLabel || '请选择目的地' }}</view>
|
||||
<picker-view class="wheel" :value="pickerValue" @change="onChange">
|
||||
<picker-view-column>
|
||||
<view v-for="item in continents" :key="item.name" class="wheel-item"><text>{{ item.name }}</text></view>
|
||||
</picker-view-column>
|
||||
<picker-view-column>
|
||||
<view v-for="item in countries" :key="item.name" class="wheel-item"><text>{{ item.name }}</text></view>
|
||||
</picker-view-column>
|
||||
<picker-view-column>
|
||||
<view v-for="item in cities" :key="item" class="wheel-item"><text>{{ item }}</text></view>
|
||||
</picker-view-column>
|
||||
</picker-view>
|
||||
<view class="footer">
|
||||
<button class="btn ghost" @tap="close">取消</button>
|
||||
<button class="btn primary" @tap="confirm">确定</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const CITY_DATA = [
|
||||
{ name: '亚洲', countries: [{ name: '中国', cities: ['北京', '上海', '广州', '深圳', '杭州', '成都'] }, { name: '日本', cities: ['东京', '大阪', '京都'] }, { name: '韩国', cities: ['首尔', '釜山'] }, { name: '新加坡', cities: ['新加坡'] }] },
|
||||
{ name: '欧洲', countries: [{ name: '英国', cities: ['伦敦', '曼彻斯特'] }, { name: '法国', cities: ['巴黎', '里昂'] }, { name: '德国', cities: ['柏林', '慕尼黑'] }] },
|
||||
{ name: '北美洲', countries: [{ name: '美国', cities: ['纽约', '洛杉矶', '旧金山'] }, { name: '加拿大', cities: ['多伦多', '温哥华'] }] },
|
||||
{ name: '大洋洲', countries: [{ name: '澳大利亚', cities: ['悉尼', '墨尔本'] }, { name: '新西兰', cities: ['奥克兰', '惠灵顿'] }] }
|
||||
]
|
||||
export default {
|
||||
props: { visible: Boolean, value: String },
|
||||
emits: ['update:visible', 'change'],
|
||||
data() { return { pickerValue: [0, 0, 0], cityData: CITY_DATA } },
|
||||
computed: {
|
||||
continents() { return this.cityData },
|
||||
countries() { return this.continents[this.pickerValue[0]]?.countries || [] },
|
||||
cities() { return this.countries[this.pickerValue[1]]?.cities || [] },
|
||||
pathLabel() { const a = this.continents[this.pickerValue[0]]?.name || ''; const b = this.countries[this.pickerValue[1]]?.name || ''; const c = this.cities[this.pickerValue[2]] || ''; return [a,b,c].filter(Boolean).join(' / ') }
|
||||
},
|
||||
watch: { visible(v) { if (v) this.initValue() } },
|
||||
methods: {
|
||||
initValue() { this.pickerValue = [0,0,0] },
|
||||
onChange(e) { this.pickerValue = (e.detail.value || []).map(v => Number(v)) },
|
||||
confirm() { this.$emit('change', this.pathLabel); this.close() },
|
||||
close() { this.$emit('update:visible', false) }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mask{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:1000;display:flex;align-items:flex-end}.sheet{width:100%;background:#fff;border-radius:24rpx 24rpx 0 0;padding:20rpx;box-sizing:border-box}.sheet-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16rpx}.sheet-title{font-size:30rpx;font-weight:800}.sheet-close{font-size:26rpx;color:#1677ff}.city-path{padding:12rpx 16rpx;margin-bottom:12rpx;border-radius:16rpx;background:#f8fafc;color:#334155;font-size:24rpx}.wheel{height:520rpx}.wheel-item{display:flex;align-items:center;justify-content:center;height:88rpx}.footer{display:flex;gap:12rpx;margin-top:16rpx}.btn{flex:1;border-radius:16rpx}.btn.ghost{background:#f3f4f6;color:#334155}.btn.primary{background:#1677ff;color:#fff}
|
||||
</style>
|
||||
70
components/hrm/DateTimePicker.vue
Normal file
70
components/hrm/DateTimePicker.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<view v-if="visible" class="mask" @tap="close">
|
||||
<view class="sheet" @tap.stop>
|
||||
<view class="sheet-header">
|
||||
<text class="sheet-title">选择时间</text>
|
||||
<text class="sheet-close" @tap="close">关闭</text>
|
||||
</view>
|
||||
<picker-view ref="picker" class="wheel" @change="onChange">
|
||||
<picker-view-column>
|
||||
<view v-for="(d, i) in dateOptions" :key="d" class="wheel-item"><text>{{ d }}</text></view>
|
||||
</picker-view-column>
|
||||
<picker-view-column>
|
||||
<view v-for="(h, i) in hourOptions" :key="h" class="wheel-item"><text>{{ h }}</text></view>
|
||||
</picker-view-column>
|
||||
</picker-view>
|
||||
<view class="footer">
|
||||
<button class="btn ghost" @tap="close">取消</button>
|
||||
<button class="btn primary" @tap="confirm">确定</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: { visible: Boolean, value: String },
|
||||
emits: ['update:visible', 'change'],
|
||||
data() { return { pickerValue: [0, 0], days: 30 } },
|
||||
computed: {
|
||||
dateOptions() { const res = []; const base = new Date(); base.setHours(0,0,0,0); for (let i = 0; i < this.days; i++) { const d = new Date(base); d.setDate(d.getDate() + i); res.push(this.formatYMD(d)) } return res },
|
||||
hourOptions() { return Array.from({ length: 24 }, (_, i) => `${i < 10 ? '0' : ''}${i}时`) }
|
||||
},
|
||||
watch: {
|
||||
visible(v) { if (v) setTimeout(() => this.initValue(), 100) }
|
||||
},
|
||||
methods: {
|
||||
initValue() {
|
||||
if (this.value) {
|
||||
const v = String(this.value).replace('T', ' ')
|
||||
const parts = v.split(' ')
|
||||
const ymd = parts[0].split('-')
|
||||
const hm = parts[1]?.split(':') || [0, 0]
|
||||
const hour = parseInt(hm[0]) || 0
|
||||
const dateIndex = this.dateOptions.indexOf(ymd.join('-'))
|
||||
this.pickerValue = [dateIndex >= 0 ? dateIndex : 0, hour]
|
||||
} else {
|
||||
this.pickerValue = [0, 0]
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
const pv = this.$refs.picker
|
||||
if (pv) pv.setValue(this.pickerValue)
|
||||
})
|
||||
},
|
||||
onChange(e) { this.pickerValue = e.detail.value },
|
||||
confirm() {
|
||||
const date = this.dateOptions[this.pickerValue[0]] || this.dateOptions[0]
|
||||
let hour = this.pickerValue[1] || 0
|
||||
if (hour >= 24) hour = 23
|
||||
this.$emit('change', `${date}T${hour < 10 ? '0' : ''}${hour}:00:00.000+08:00`)
|
||||
this.close()
|
||||
},
|
||||
close() { this.$emit('update:visible', false) },
|
||||
formatYMD(d) { const p = n => (n < 10 ? `0${n}` : n); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}` }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mask{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:1000;display:flex;align-items:flex-end}.sheet{width:100%;background:#fff;border-radius:24rpx 24rpx 0 0;padding:20rpx;box-sizing:border-box}.sheet-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16rpx}.sheet-title{font-size:30rpx;font-weight:800}.sheet-close{font-size:26rpx;color:#1677ff}.wheel{height:520rpx}.wheel-item{display:flex;align-items:center;justify-content:center;height:88rpx}.footer{display:flex;gap:12rpx;margin-top:16rpx}.btn{flex:1;border-radius:16rpx}.btn.ghost{background:#f3f4f6;color:#334155}.btn.primary{background:#1677ff;color:#fff}
|
||||
</style>
|
||||
72
components/hrm/EmployeePicker.vue
Normal file
72
components/hrm/EmployeePicker.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<view v-if="visible" class="mask" @tap="close">
|
||||
<view class="sheet" @tap.stop>
|
||||
<view class="sheet-header">
|
||||
<text class="sheet-title">{{ title }}</text>
|
||||
<text class="sheet-close" @tap="close">关闭</text>
|
||||
</view>
|
||||
<input class="search" v-model="keyword" placeholder="搜索姓名/部门" />
|
||||
<scroll-view class="list" scroll-y>
|
||||
<view v-for="item in filteredList" :key="item.userId || item.id" class="list-item" @tap="select(item)">
|
||||
<view class="list-main">
|
||||
<view class="list-name">{{ getName(item) }}</view>
|
||||
<view class="list-sub">{{ getDept(item) }}</view>
|
||||
</view>
|
||||
<view class="check" :class="isSelected(item) ? 'selected' : ''"></view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="footer">
|
||||
<button class="btn ghost" @tap="close">取消</button>
|
||||
<button v-if="multiple" class="btn primary" @tap="confirm">确定</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
visible: Boolean,
|
||||
title: { type: String, default: '选择人员' },
|
||||
list: { type: Array, default: () => [] },
|
||||
multiple: { type: Boolean, default: false },
|
||||
value: { type: [Object, Array, null], default: null }
|
||||
},
|
||||
emits: ['update:visible', 'change', 'confirm'],
|
||||
data() { return { keyword: '' } },
|
||||
computed: {
|
||||
filteredList() {
|
||||
const kw = this.keyword.trim().toLowerCase()
|
||||
if (!kw) return this.list
|
||||
return this.list.filter(item => `${this.getName(item)} ${this.getDept(item)}`.toLowerCase().includes(kw))
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getName(item) { return item?.empName || item?.nickName || item?.userName || item?.realName || `员工${item?.userId || item?.id || ''}` },
|
||||
getDept(item) { return item?.dept?.deptName || item?.deptName || '未分配部门' },
|
||||
isSelected(item) {
|
||||
if (!this.multiple) return this.value && String((this.value.userId || this.value.id)) === String(item.userId || item.id)
|
||||
return Array.isArray(this.value) && this.value.some(v => String((v.userId || v.id)) === String(item.userId || item.id))
|
||||
},
|
||||
select(item) {
|
||||
if (this.multiple) {
|
||||
const arr = Array.isArray(this.value) ? [...this.value] : []
|
||||
const id = String(item.userId || item.id)
|
||||
const idx = arr.findIndex(v => String(v.userId || v.id) === id)
|
||||
if (idx >= 0) arr.splice(idx, 1)
|
||||
else arr.push(item)
|
||||
this.$emit('change', arr)
|
||||
return
|
||||
}
|
||||
this.$emit('change', item)
|
||||
this.close()
|
||||
},
|
||||
confirm() { this.$emit('confirm'); this.close() },
|
||||
close() { this.$emit('update:visible', false) }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mask{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:1000;display:flex;align-items:flex-end}.sheet{width:100%;background:#fff;border-radius:24rpx 24rpx 0 0;padding:20rpx;box-sizing:border-box}.sheet-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16rpx}.sheet-title{font-size:30rpx;font-weight:800}.sheet-close{font-size:26rpx;color:#1677ff}.search{width:100%;box-sizing:border-box;padding:18rpx 20rpx;border:1rpx solid #e5e7eb;border-radius:16rpx;background:#f9fafb;font-size:26rpx;margin-bottom:16rpx}.list{max-height:60vh}.list-item{display:flex;justify-content:space-between;align-items:center;padding:18rpx 4rpx;border-bottom:1rpx solid #f3f4f6}.list-name{font-size:28rpx;font-weight:700}.list-sub{font-size:22rpx;color:#94a3b8;margin-top:4rpx}.check{width:28rpx;height:28rpx;border-radius:50%;border:2rpx solid #cbd5e1}.check.selected{background:#1677ff;border-color:#1677ff}.footer{display:flex;gap:12rpx;margin-top:16rpx}.btn{flex:1;border-radius:16rpx}.btn.ghost{background:#f3f4f6;color:#334155}.btn.primary{background:#1677ff;color:#fff}
|
||||
</style>
|
||||
152
components/hrm/GlobalCityPicker.vue
Normal file
152
components/hrm/GlobalCityPicker.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<view v-if="visible" class="picker-mask" @tap="close">
|
||||
<view class="picker-sheet" @tap.stop>
|
||||
<view class="picker-header">
|
||||
<text class="picker-title">选择目的地</text>
|
||||
<text class="picker-close" @tap="close">关闭</text>
|
||||
</view>
|
||||
|
||||
<view class="city-path">{{ currentPath }}</view>
|
||||
|
||||
<picker-view class="city-wheel" :value="wheelValue" @change="onChange">
|
||||
<picker-view-column>
|
||||
<view class="wheel-item" v-for="item in continents" :key="item.name">
|
||||
<view class="wheel-value">{{ item.name }}</view>
|
||||
</view>
|
||||
</picker-view-column>
|
||||
<picker-view-column>
|
||||
<view class="wheel-item" v-for="item in currentCountries" :key="item.name">
|
||||
<view class="wheel-value">{{ item.name }}</view>
|
||||
</view>
|
||||
</picker-view-column>
|
||||
<picker-view-column>
|
||||
<view class="wheel-item" v-for="item in currentCities" :key="item.name">
|
||||
<view class="wheel-value">{{ item.name }}</view>
|
||||
</view>
|
||||
</picker-view-column>
|
||||
</picker-view>
|
||||
|
||||
<view class="picker-footer">
|
||||
<button class="btn ghost" @tap="close">取消</button>
|
||||
<button class="btn primary" @tap="confirm">确定</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const CITY_DATA = [
|
||||
{ name: '亚洲', countries: [
|
||||
{ name: '中国', cities: ['北京', '上海', '广州', '深圳', '杭州', '成都', '重庆', '西安', '南京', '武汉'] },
|
||||
{ name: '日本', cities: ['东京', '大阪', '京都', '名古屋', '横滨'] },
|
||||
{ name: '韩国', cities: ['首尔', '釜山', '仁川'] },
|
||||
{ name: '新加坡', cities: ['新加坡'] },
|
||||
{ name: '泰国', cities: ['曼谷', '清迈', '普吉'] },
|
||||
{ name: '马来西亚', cities: ['吉隆坡', '槟城', '新山'] },
|
||||
{ name: '印度', cities: ['新德里', '孟买', '班加罗尔'] },
|
||||
{ name: '阿联酋', cities: ['迪拜', '阿布扎比'] }
|
||||
]},
|
||||
{ name: '欧洲', countries: [
|
||||
{ name: '英国', cities: ['伦敦', '曼彻斯特', '伯明翰'] },
|
||||
{ name: '法国', cities: ['巴黎', '里昂', '马赛'] },
|
||||
{ name: '德国', cities: ['柏林', '慕尼黑', '法兰克福'] },
|
||||
{ name: '意大利', cities: ['罗马', '米兰', '威尼斯'] },
|
||||
{ name: '西班牙', cities: ['马德里', '巴塞罗那', '瓦伦西亚'] },
|
||||
{ name: '荷兰', cities: ['阿姆斯特丹', '鹿特丹'] }
|
||||
]},
|
||||
{ name: '北美洲', countries: [
|
||||
{ name: '美国', cities: ['纽约', '洛杉矶', '旧金山', '芝加哥', '西雅图'] },
|
||||
{ name: '加拿大', cities: ['多伦多', '温哥华', '蒙特利尔'] },
|
||||
{ name: '墨西哥', cities: ['墨西哥城', '瓜达拉哈拉'] }
|
||||
]},
|
||||
{ name: '南美洲', countries: [
|
||||
{ name: '巴西', cities: ['圣保罗', '里约热内卢', '巴西利亚'] },
|
||||
{ name: '阿根廷', cities: ['布宜诺斯艾利斯', '科尔多瓦'] },
|
||||
{ name: '智利', cities: ['圣地亚哥'] }
|
||||
]},
|
||||
{ name: '大洋洲', countries: [
|
||||
{ name: '澳大利亚', cities: ['悉尼', '墨尔本', '布里斯班', '珀斯'] },
|
||||
{ name: '新西兰', cities: ['奥克兰', '惠灵顿', '基督城'] }
|
||||
]},
|
||||
{ name: '非洲', countries: [
|
||||
{ name: '埃及', cities: ['开罗', '亚历山大'] },
|
||||
{ name: '南非', cities: ['约翰内斯堡', '开普敦'] },
|
||||
{ name: '肯尼亚', cities: ['内罗毕'] }
|
||||
]}
|
||||
]
|
||||
|
||||
export default {
|
||||
props: { visible: Boolean, value: String },
|
||||
emits: ['update:visible', 'change'],
|
||||
data() {
|
||||
return {
|
||||
wheelValue: [0, 0, 0],
|
||||
data: CITY_DATA
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
continents() { return this.data },
|
||||
currentCountries() { return this.continents[this.wheelValue[0]]?.countries || [] },
|
||||
currentCities() { return this.currentCountries[this.wheelValue[1]]?.cities?.map(name => ({ name })) || [] },
|
||||
currentPath() {
|
||||
const c1 = this.continents[this.wheelValue[0]]?.name || ''
|
||||
const c2 = this.currentCountries[this.wheelValue[1]]?.name || ''
|
||||
const c3 = this.currentCities[this.wheelValue[2]]?.name || ''
|
||||
return [c1, c2, c3].filter(Boolean).join(' / ') || '请选择目的地'
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
visible(val) {
|
||||
if (val) this.initFromValue()
|
||||
},
|
||||
wheelValue() {
|
||||
this.normalize()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
initFromValue() {
|
||||
const target = String(this.value || '')
|
||||
let found = [0, 0, 0]
|
||||
this.data.forEach((continent, i) => {
|
||||
continent.countries.forEach((country, j) => {
|
||||
country.cities.forEach((city, k) => {
|
||||
const full = `${continent.name}/${country.name}/${city}`
|
||||
if (target === full || target === city) found = [i, j, k]
|
||||
})
|
||||
})
|
||||
})
|
||||
this.wheelValue = found
|
||||
},
|
||||
normalize() {
|
||||
const c = this.continents[this.wheelValue[0]]
|
||||
if (!c) return
|
||||
if (this.wheelValue[1] >= c.countries.length) this.wheelValue[1] = 0
|
||||
const country = c.countries[this.wheelValue[1]]
|
||||
if (!country) return
|
||||
if (this.wheelValue[2] >= country.cities.length) this.wheelValue[2] = 0
|
||||
},
|
||||
onChange(e) {
|
||||
this.wheelValue = (e.detail.value || []).map(v => Number(v))
|
||||
},
|
||||
confirm() {
|
||||
const continent = this.continents[this.wheelValue[0]]
|
||||
const country = this.currentCountries[this.wheelValue[1]]
|
||||
const city = this.currentCities[this.wheelValue[2]]
|
||||
const payload = {
|
||||
continent: continent?.name || '',
|
||||
country: country?.name || '',
|
||||
city: city?.name || '',
|
||||
label: [continent?.name, country?.name, city?.name].filter(Boolean).join(' / '),
|
||||
value: [continent?.name, country?.name, city?.name].filter(Boolean).join('/')
|
||||
}
|
||||
this.$emit('change', payload)
|
||||
this.$emit('update:visible', false)
|
||||
},
|
||||
close() { this.$emit('update:visible', false) }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.picker-mask{position:fixed;inset:0;background:rgba(15,23,42,.45);z-index:1000;display:flex;align-items:flex-end}.picker-sheet{width:100%;background:#fff;border-radius:24rpx 24rpx 0 0;padding:20rpx;box-sizing:border-box}.picker-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16rpx}.picker-title{font-size:30rpx;font-weight:800;color:#111827}.picker-close{font-size:26rpx;color:#1677ff}.city-path{padding:12rpx 16rpx;margin-bottom:12rpx;border-radius:16rpx;background:#f8fafc;color:#334155;font-size:24rpx}.city-wheel{height:520rpx;display:flex}.wheel-item{display:flex;align-items:center;justify-content:center;height:88rpx}.wheel-value{font-size:28rpx;font-weight:600;color:#111827}.picker-footer{display:flex;gap:12rpx;margin-top:16rpx}.btn{flex:1;border-radius:16rpx}.btn.ghost{background:#f3f4f6;color:#334155}.btn.primary{background:linear-gradient(135deg,#1677ff,#4f9dff);color:#fff}
|
||||
</style>
|
||||
166
components/hrm/RequestForm.vue
Normal file
166
components/hrm/RequestForm.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user