| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523 |
- <template>
- <view class="app-container pb-20">
- <StatusBar v-if="showStatusBar" />
- <TopBar v-if="showTopBar" title="排班管理" :show-back="showBack" />
- <!-- tab 页面专用标题栏 -->
- <view v-if="!showTopBar" class="bg-white px-4 py-3 flex items-center justify-between border-b border-gray-100">
- <text class="text-base font-semibold text-gray-800">排班管理</text>
- </view>
- <!-- 月份切换 -->
- <view class="bg-white px-4 py-3 flex items-center justify-center border-b border-gray-100">
- <view class="flex items-center">
- <view class="p-2" @click="changeMonth(-1)">
- <text class="uni-icons uniui-back text-gray-600"></text>
- </view>
- <text class="text-base font-semibold text-gray-800 mx-2">{{ currentYear }}年{{ currentMonth }}月</text>
- <view class="p-2" @click="changeMonth(1)">
- <text class="uni-icons uniui-arrowright text-gray-600"></text>
- </view>
- </view>
- </view>
- <!-- 页面中部操作按钮 -->
- <view class="bg-white px-4 py-2 flex items-center justify-center gap-6 border-b border-gray-100">
- <view class="text-sm text-blue-500 flex items-center" @click="goToToday">
- <text class="uni-icons uniui-calendar-filled mr-1"></text>
- 今天
- </view>
- <view class="text-blue-500 text-sm flex items-center" @click="addSchedule">
- <text class="uni-icons uniui-plusempty mr-1"></text>
- 新增排班
- </view>
- </view>
- <!-- 日历 -->
- <view class="mx-4 mt-4 bg-white rounded-xl shadow-sm p-4">
- <!-- 星期标题 -->
- <view class="flex mb-2">
- <view v-for="day in weekDays" :key="day" class="flex-1 text-center">
- <text class="text-xs text-gray-400">{{ day }}</text>
- </view>
- </view>
- <!-- 日期 -->
- <view v-for="(week, weekIndex) in calendarWeeks" :key="weekIndex" class="flex mb-2">
- <view
- v-for="(date, dateIndex) in week"
- :key="dateIndex"
- class="flex-1 h-14 flex flex-col items-center justify-center relative mx-1 rounded-xl transition-transform duration-150"
- :class="{
- 'opacity-30': date.isOtherMonth,
- 'bg-blue-50 text-primary': date.isToday && !isSelected(date),
- 'bg-primary text-white shadow-sm': isSelected(date),
- }"
- hover-class="scale-down"
- :hover-start-time="0"
- :hover-stay-time="150"
- @click="selectDate(date)"
- >
- <text
- class="text-sm font-medium"
- :class="{
- 'text-primary': date.isToday && !isSelected(date),
- 'text-white': isSelected(date),
- 'text-gray-800': !date.isToday && !isSelected(date)
- }"
- >
- {{ date.day }}
- </text>
- <view
- v-if="date.hasSchedule"
- class="w-2 h-2 rounded-full mt-1"
- :class="isSelected(date) ? 'bg-white' : 'bg-primary'"
- ></view>
- </view>
- </view>
- </view>
- <!-- 排班列表 -->
- <view class="px-4 mt-4">
- <view class="flex justify-between items-center mb-3">
- <text class="text-base font-semibold text-gray-800">{{ selectedDate }} 排班</text>
- </view>
- <view v-if="loading" class="py-10 text-center">
- <text class="text-gray-400">加载中...</text>
- </view>
- <view v-else-if="daySchedules.length === 0">
- <EmptyState message="该日无排班" />
- </view>
- <view v-else>
- <view
- v-for="schedule in daySchedules"
- :key="schedule.scheduleId"
- class="bg-white rounded-xl shadow-sm mb-3"
- hover-class="scale-down"
- :hover-start-time="0"
- :hover-stay-time="150"
- @click="viewSchedule(schedule)"
- >
- <uni-list-item
- :title="schedule.teamName || '未命名班组'"
- :note="(schedule.staffIds || '未分配人员') + ' | ' + (schedule.vehicleIds || '未分配车辆')"
- :right-text="getStatusText(schedule.status)"
- show-arrow
- clickable
- >
- <template #header>
- <view class="w-10 h-10 rounded-xl flex items-center justify-center mr-3" :class="getScheduleIconBgClass(schedule.shiftType)">
- <text class="uni-icons text-lg" :class="getScheduleIconClass(schedule.shiftType)"></text>
- </view>
- </template>
- </uni-list-item>
- </view>
- </view>
- </view>
- <!-- 新增排班底部弹窗 -->
- <view v-if="showModal" class="fixed inset-0 z-50 flex flex-col justify-end" style="padding-bottom: var(--window-bottom, 0);">
- <view class="absolute inset-0" style="background-color: rgba(0, 0, 0, 0.5);" @click="closeModal"></view>
- <view class="relative bg-white rounded-t-3xl w-full p-4 overflow-y-auto" style="max-height: 60vh; margin-bottom: 100rpx;">
- <view class="flex justify-between items-center mb-4">
- <text class="text-base font-semibold text-gray-800">{{ isDetail ? '排班详情' : '新增排班' }}</text>
- <text class="uni-icons uniui-close text-gray-400 p-2" @click="closeModal"></text>
- </view>
- <view class="mb-4">
- <text class="text-sm text-gray-600 block mb-1">选择班组</text>
- <picker mode="selector" :range="teamOptions" range-key="label" :value="teamIndex" @change="(e: any) => { const idx = e.detail.value; form.teamId = teamOptions[idx].value; onTeamChange(form.teamId) }">
- <view class="border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-800">
- {{ teamNameDisplay || '请选择班组' }}
- </view>
- </picker>
- </view>
- <view class="mb-4">
- <text class="text-sm text-gray-600 block mb-1">工作日期</text>
- <picker mode="date" :value="form.workDate" @change="(e: any) => form.workDate = e.detail.value">
- <view class="border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-800">
- {{ form.workDate || '请选择日期' }}
- </view>
- </picker>
- </view>
- <view class="mb-4">
- <text class="text-sm text-gray-600 block mb-1">排班类型</text>
- <view class="flex rounded-lg border border-gray-200 overflow-hidden">
- <view
- class="flex-1 py-2 text-center text-sm"
- :class="form.shiftType === 'month_plan' ? 'bg-primary text-white' : 'bg-white text-gray-600'"
- @click="form.shiftType = 'month_plan'"
- >月计划</view>
- <view
- class="flex-1 py-2 text-center text-sm"
- :class="form.shiftType === 'emergency' ? 'bg-red-500 text-white' : 'bg-white text-gray-600'"
- @click="form.shiftType = 'emergency'"
- >应急</view>
- </view>
- </view>
- <view class="mb-4">
- <text class="text-sm text-gray-600 block mb-1">选择车辆</text>
- <view class="flex flex-wrap gap-2">
- <view
- v-for="v in vehicleOptions"
- :key="v.value"
- class="px-2 py-1 rounded-lg text-xs border"
- :class="form.vehicleIds.includes(v.value) ? 'bg-primary text-white border-primary' : 'bg-white text-gray-600 border-gray-200'"
- @click="toggleVehicle(v.value)"
- >
- {{ v.label }}
- </view>
- </view>
- </view>
- <view class="mb-4">
- <text class="text-sm text-gray-600 block mb-1">备注(可选)</text>
- <textarea
- v-model="form.remark"
- class="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm"
- style="min-height: 80px;"
- placeholder="请输入备注信息..."
- />
- </view>
- <view class="flex gap-3 mt-4">
- <button class="flex-1 py-3 bg-white text-gray-600 rounded-xl border border-gray-200 text-sm transition-colors" hover-class="bg-gray-50" :hover-start-time="0" :hover-stay-time="150" @click="closeModal">取消</button>
- <button class="flex-1 py-3 bg-primary text-white rounded-xl text-sm transition-colors" hover-class="bg-blue-600" :hover-start-time="0" :hover-stay-time="150" @click="submitSchedule">{{ isDetail ? '保存' : '确认' }}</button>
- </view>
- </view>
- </view>
- </view>
- </template>
- <script setup lang="ts">
- import { ref, computed, onMounted, watch } from 'vue'
- import StatusBar from '../common/StatusBar.vue'
- import TopBar from '../common/TopBar.vue'
- import EmptyState from '../common/EmptyState.vue'
- import UniList from '../uni-list/uni-list.vue'
- import UniListItem from '../uni-list/uni-list-item.vue'
- import { getSchedulesByDate, createSchedule, updateSchedule, getScheduleList } from '@/api/schedule'
- import { getTeamSimpleList, getTeamMembers } from '@/api/team'
- import { getVehicleSimpleList } from '@/api/vehicle'
- withDefaults(
- defineProps<{
- showTopBar?: boolean
- showBack?: boolean
- showStatusBar?: boolean
- }>(),
- {
- showTopBar: true,
- showBack: true,
- showStatusBar: false,
- }
- )
- const loading = ref(false)
- const schedules = ref<any[]>([])
- const currentYear = ref(new Date().getFullYear())
- const currentMonth = ref(new Date().getMonth() + 1)
- const selectedDate = ref('')
- const showModal = ref(false)
- const isDetail = ref(false)
- const currentScheduleId = ref<number | undefined>(undefined)
- const teamOptions = ref<Array<{ value: number; label: string }>>([])
- const vehicleOptions = ref<Array<{ value: number; label: string }>>([])
- const form = ref({
- teamId: undefined as number | undefined,
- teamName: '',
- workDate: '',
- shiftType: 'month_plan' as 'month_plan' | 'emergency',
- vehicleIds: [] as number[],
- staffIds: '',
- status: 'idle',
- remark: '',
- })
- const teamIndex = computed(() => teamOptions.value.findIndex(t => t.value === form.value.teamId))
- const teamNameDisplay = computed(() => teamOptions.value.find(t => t.value === form.value.teamId)?.label)
- const weekDays = ['日', '一', '二', '三', '四', '五', '六']
- const calendarWeeks = computed(() => {
- const year = currentYear.value
- const month = currentMonth.value
- const firstDay = new Date(year, month - 1, 1)
- const lastDay = new Date(year, month, 0)
- const firstWeekDay = firstDay.getDay()
- const daysInMonth = lastDay.getDate()
- const weeks: any[] = []
- let currentWeek: any[] = []
- const prevMonthDays = new Date(year, month - 1, 0).getDate()
- for (let i = firstWeekDay - 1; i >= 0; i--) {
- currentWeek.push({
- day: prevMonthDays - i,
- isOtherMonth: true,
- isToday: false,
- hasSchedule: false,
- })
- }
- for (let day = 1; day <= daysInMonth; day++) {
- const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
- const isToday = dateStr === new Date().toISOString().split('T')[0]
- const hasSchedule = schedules.value.some(s => s.workDate === dateStr)
- currentWeek.push({
- day,
- isOtherMonth: false,
- isToday,
- hasSchedule,
- dateStr,
- })
- if (currentWeek.length === 7) {
- weeks.push(currentWeek)
- currentWeek = []
- }
- }
- if (currentWeek.length > 0) {
- for (let i = 1; currentWeek.length < 7; i++) {
- currentWeek.push({
- day: i,
- isOtherMonth: true,
- isToday: false,
- hasSchedule: false,
- })
- }
- weeks.push(currentWeek)
- }
- return weeks
- })
- const daySchedules = computed(() => {
- if (!selectedDate.value) return []
- return schedules.value.filter(s => s.workDate === selectedDate.value)
- })
- function changeMonth(delta: number) {
- let newMonth = currentMonth.value + delta
- if (newMonth > 12) {
- newMonth = 1
- currentYear.value++
- } else if (newMonth < 1) {
- newMonth = 12
- currentYear.value--
- }
- currentMonth.value = newMonth
- }
- function goToToday() {
- const now = new Date()
- currentYear.value = now.getFullYear()
- currentMonth.value = now.getMonth() + 1
- selectedDate.value = now.toISOString().split('T')[0]
- }
- function selectDate(date: any) {
- if (!date.isOtherMonth) {
- selectedDate.value = date.dateStr
- }
- }
- function isSelected(date: any): boolean {
- return date.dateStr === selectedDate.value
- }
- function getStatusText(status: string) {
- const map: Record<string, string> = {
- scheduled: '已排班',
- completed: '已完成',
- cancelled: '已取消',
- }
- return map[status] || status
- }
- function getScheduleIconBgClass(shiftType: string) {
- return shiftType === 'emergency' ? 'bg-red-50' : 'bg-blue-50'
- }
- function getScheduleIconClass(shiftType: string) {
- return shiftType === 'emergency' ? 'uniui-calendar-filled text-red-500' : 'uniui-calendar-filled text-blue-500'
- }
- function toggleVehicle(vehicleId: number) {
- const idx = form.value.vehicleIds.indexOf(vehicleId)
- if (idx >= 0) {
- form.value.vehicleIds.splice(idx, 1)
- } else {
- form.value.vehicleIds.push(vehicleId)
- }
- }
- async function addSchedule() {
- isDetail.value = false
- currentScheduleId.value = undefined
- resetForm()
- form.value.workDate = selectedDate.value
- showModal.value = true
- if (teamOptions.value.length === 0) {
- try {
- teamOptions.value = await getTeamSimpleList()
- } catch (e) {
- console.error('加载班组失败', e)
- }
- }
- if (vehicleOptions.value.length === 0) {
- try {
- vehicleOptions.value = await getVehicleSimpleList()
- } catch (e) {
- console.error('加载车辆失败', e)
- }
- }
- }
- function normalizeShiftType(shiftType: string | undefined): 'month_plan' | 'emergency' {
- if (!shiftType) return 'month_plan'
- if (shiftType === 'emergency' || shiftType.includes('应急')) return 'emergency'
- return 'month_plan'
- }
- function viewSchedule(schedule: any) {
- isDetail.value = true
- currentScheduleId.value = schedule.scheduleId
- form.value = {
- teamId: schedule.teamId,
- teamName: schedule.teamName || '',
- workDate: schedule.workDate || '',
- shiftType: normalizeShiftType(schedule.shiftType),
- vehicleIds: parseVehicleIds(schedule.vehicleIds),
- staffIds: schedule.staffIds || '',
- status: schedule.status || 'idle',
- remark: schedule.remark || '',
- }
- showModal.value = true
- if (teamOptions.value.length === 0) {
- getTeamSimpleList().then(res => { teamOptions.value = res }).catch(e => console.error('加载班组失败', e))
- }
- if (vehicleOptions.value.length === 0) {
- getVehicleSimpleList().then(res => { vehicleOptions.value = res }).catch(e => console.error('加载车辆失败', e))
- }
- }
- function parseVehicleIds(ids: string | number[] | undefined): number[] {
- if (!ids) return []
- if (Array.isArray(ids)) return ids.map(Number)
- return ids.split(',').map(s => Number(s.trim())).filter(n => !isNaN(n))
- }
- function closeModal() {
- showModal.value = false
- isDetail.value = false
- currentScheduleId.value = undefined
- }
- function resetForm() {
- form.value = {
- teamId: undefined,
- teamName: '',
- workDate: selectedDate.value,
- shiftType: 'month_plan',
- vehicleIds: [],
- staffIds: '',
- status: 'idle',
- remark: '',
- }
- }
- async function onTeamChange(teamId: number) {
- const team = teamOptions.value.find(t => t.value === teamId)
- form.value.teamName = team?.label || ''
- try {
- const members = await getTeamMembers(teamId)
- form.value.staffIds = members.map(m => m.staffId).join(',')
- } catch (e) {
- console.error('加载班组成员失败', e)
- }
- }
- async function submitSchedule() {
- if (!form.value.teamId) {
- uni.showToast({ title: '请选择班组', icon: 'none' })
- return
- }
- if (!form.value.workDate) {
- uni.showToast({ title: '请选择工作日期', icon: 'none' })
- return
- }
- try {
- const payload = {
- teamId: form.value.teamId,
- teamName: form.value.teamName,
- workDate: form.value.workDate,
- shiftType: form.value.shiftType,
- vehicleIds: form.value.vehicleIds.join(','),
- staffIds: form.value.staffIds,
- status: form.value.status,
- remark: form.value.remark,
- }
- if (isDetail.value && currentScheduleId.value) {
- await updateSchedule(currentScheduleId.value, payload)
- uni.showToast({ title: '保存成功', icon: 'success' })
- } else {
- await createSchedule(payload)
- uni.showToast({ title: '新增排班成功', icon: 'success' })
- }
- closeModal()
- resetForm()
- await fetchSchedules()
- } catch (e) {
- console.error(isDetail.value ? '保存排班失败' : '新增排班失败', e)
- }
- }
- async function fetchSchedules() {
- if (!selectedDate.value) return
- loading.value = true
- try {
- const [dayRes, monthRes] = await Promise.all([
- getSchedulesByDate(selectedDate.value),
- getScheduleList(currentYear.value, currentMonth.value)
- ])
- const dayList = dayRes || []
- const monthList = monthRes?.rows || monthRes || []
- const allSchedules = [...dayList]
- monthList.forEach((s: any) => {
- if (!allSchedules.some((x: any) => x.scheduleId === s.scheduleId)) {
- allSchedules.push(s)
- }
- })
- schedules.value = allSchedules
- } catch (error) {
- console.error('获取排班列表失败:', error)
- } finally {
- loading.value = false
- }
- }
- watch([selectedDate, currentMonth], fetchSchedules)
- onMounted(() => {
- const today = new Date().toISOString().split('T')[0]
- selectedDate.value = today
- fetchSchedules()
- })
- </script>
- <style scoped>
- .scale-down {
- transition: transform 0.15s ease;
- transform: scale(0.95);
- }
- </style>
|