ScheduleView.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523
  1. <template>
  2. <view class="app-container pb-20">
  3. <StatusBar v-if="showStatusBar" />
  4. <TopBar v-if="showTopBar" title="排班管理" :show-back="showBack" />
  5. <!-- tab 页面专用标题栏 -->
  6. <view v-if="!showTopBar" class="bg-white px-4 py-3 flex items-center justify-between border-b border-gray-100">
  7. <text class="text-base font-semibold text-gray-800">排班管理</text>
  8. </view>
  9. <!-- 月份切换 -->
  10. <view class="bg-white px-4 py-3 flex items-center justify-center border-b border-gray-100">
  11. <view class="flex items-center">
  12. <view class="p-2" @click="changeMonth(-1)">
  13. <text class="uni-icons uniui-back text-gray-600"></text>
  14. </view>
  15. <text class="text-base font-semibold text-gray-800 mx-2">{{ currentYear }}年{{ currentMonth }}月</text>
  16. <view class="p-2" @click="changeMonth(1)">
  17. <text class="uni-icons uniui-arrowright text-gray-600"></text>
  18. </view>
  19. </view>
  20. </view>
  21. <!-- 页面中部操作按钮 -->
  22. <view class="bg-white px-4 py-2 flex items-center justify-center gap-6 border-b border-gray-100">
  23. <view class="text-sm text-blue-500 flex items-center" @click="goToToday">
  24. <text class="uni-icons uniui-calendar-filled mr-1"></text>
  25. 今天
  26. </view>
  27. <view class="text-blue-500 text-sm flex items-center" @click="addSchedule">
  28. <text class="uni-icons uniui-plusempty mr-1"></text>
  29. 新增排班
  30. </view>
  31. </view>
  32. <!-- 日历 -->
  33. <view class="mx-4 mt-4 bg-white rounded-xl shadow-sm p-4">
  34. <!-- 星期标题 -->
  35. <view class="flex mb-2">
  36. <view v-for="day in weekDays" :key="day" class="flex-1 text-center">
  37. <text class="text-xs text-gray-400">{{ day }}</text>
  38. </view>
  39. </view>
  40. <!-- 日期 -->
  41. <view v-for="(week, weekIndex) in calendarWeeks" :key="weekIndex" class="flex mb-2">
  42. <view
  43. v-for="(date, dateIndex) in week"
  44. :key="dateIndex"
  45. class="flex-1 h-14 flex flex-col items-center justify-center relative mx-1 rounded-xl transition-transform duration-150"
  46. :class="{
  47. 'opacity-30': date.isOtherMonth,
  48. 'bg-blue-50 text-primary': date.isToday && !isSelected(date),
  49. 'bg-primary text-white shadow-sm': isSelected(date),
  50. }"
  51. hover-class="scale-down"
  52. :hover-start-time="0"
  53. :hover-stay-time="150"
  54. @click="selectDate(date)"
  55. >
  56. <text
  57. class="text-sm font-medium"
  58. :class="{
  59. 'text-primary': date.isToday && !isSelected(date),
  60. 'text-white': isSelected(date),
  61. 'text-gray-800': !date.isToday && !isSelected(date)
  62. }"
  63. >
  64. {{ date.day }}
  65. </text>
  66. <view
  67. v-if="date.hasSchedule"
  68. class="w-2 h-2 rounded-full mt-1"
  69. :class="isSelected(date) ? 'bg-white' : 'bg-primary'"
  70. ></view>
  71. </view>
  72. </view>
  73. </view>
  74. <!-- 排班列表 -->
  75. <view class="px-4 mt-4">
  76. <view class="flex justify-between items-center mb-3">
  77. <text class="text-base font-semibold text-gray-800">{{ selectedDate }} 排班</text>
  78. </view>
  79. <view v-if="loading" class="py-10 text-center">
  80. <text class="text-gray-400">加载中...</text>
  81. </view>
  82. <view v-else-if="daySchedules.length === 0">
  83. <EmptyState message="该日无排班" />
  84. </view>
  85. <view v-else>
  86. <view
  87. v-for="schedule in daySchedules"
  88. :key="schedule.scheduleId"
  89. class="bg-white rounded-xl shadow-sm mb-3"
  90. hover-class="scale-down"
  91. :hover-start-time="0"
  92. :hover-stay-time="150"
  93. @click="viewSchedule(schedule)"
  94. >
  95. <uni-list-item
  96. :title="schedule.teamName || '未命名班组'"
  97. :note="(schedule.staffIds || '未分配人员') + ' | ' + (schedule.vehicleIds || '未分配车辆')"
  98. :right-text="getStatusText(schedule.status)"
  99. show-arrow
  100. clickable
  101. >
  102. <template #header>
  103. <view class="w-10 h-10 rounded-xl flex items-center justify-center mr-3" :class="getScheduleIconBgClass(schedule.shiftType)">
  104. <text class="uni-icons text-lg" :class="getScheduleIconClass(schedule.shiftType)"></text>
  105. </view>
  106. </template>
  107. </uni-list-item>
  108. </view>
  109. </view>
  110. </view>
  111. <!-- 新增排班底部弹窗 -->
  112. <view v-if="showModal" class="fixed inset-0 z-50 flex flex-col justify-end" style="padding-bottom: var(--window-bottom, 0);">
  113. <view class="absolute inset-0" style="background-color: rgba(0, 0, 0, 0.5);" @click="closeModal"></view>
  114. <view class="relative bg-white rounded-t-3xl w-full p-4 overflow-y-auto" style="max-height: 60vh; margin-bottom: 100rpx;">
  115. <view class="flex justify-between items-center mb-4">
  116. <text class="text-base font-semibold text-gray-800">{{ isDetail ? '排班详情' : '新增排班' }}</text>
  117. <text class="uni-icons uniui-close text-gray-400 p-2" @click="closeModal"></text>
  118. </view>
  119. <view class="mb-4">
  120. <text class="text-sm text-gray-600 block mb-1">选择班组</text>
  121. <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) }">
  122. <view class="border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-800">
  123. {{ teamNameDisplay || '请选择班组' }}
  124. </view>
  125. </picker>
  126. </view>
  127. <view class="mb-4">
  128. <text class="text-sm text-gray-600 block mb-1">工作日期</text>
  129. <picker mode="date" :value="form.workDate" @change="(e: any) => form.workDate = e.detail.value">
  130. <view class="border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-800">
  131. {{ form.workDate || '请选择日期' }}
  132. </view>
  133. </picker>
  134. </view>
  135. <view class="mb-4">
  136. <text class="text-sm text-gray-600 block mb-1">排班类型</text>
  137. <view class="flex rounded-lg border border-gray-200 overflow-hidden">
  138. <view
  139. class="flex-1 py-2 text-center text-sm"
  140. :class="form.shiftType === 'month_plan' ? 'bg-primary text-white' : 'bg-white text-gray-600'"
  141. @click="form.shiftType = 'month_plan'"
  142. >月计划</view>
  143. <view
  144. class="flex-1 py-2 text-center text-sm"
  145. :class="form.shiftType === 'emergency' ? 'bg-red-500 text-white' : 'bg-white text-gray-600'"
  146. @click="form.shiftType = 'emergency'"
  147. >应急</view>
  148. </view>
  149. </view>
  150. <view class="mb-4">
  151. <text class="text-sm text-gray-600 block mb-1">选择车辆</text>
  152. <view class="flex flex-wrap gap-2">
  153. <view
  154. v-for="v in vehicleOptions"
  155. :key="v.value"
  156. class="px-2 py-1 rounded-lg text-xs border"
  157. :class="form.vehicleIds.includes(v.value) ? 'bg-primary text-white border-primary' : 'bg-white text-gray-600 border-gray-200'"
  158. @click="toggleVehicle(v.value)"
  159. >
  160. {{ v.label }}
  161. </view>
  162. </view>
  163. </view>
  164. <view class="mb-4">
  165. <text class="text-sm text-gray-600 block mb-1">备注(可选)</text>
  166. <textarea
  167. v-model="form.remark"
  168. class="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm"
  169. style="min-height: 80px;"
  170. placeholder="请输入备注信息..."
  171. />
  172. </view>
  173. <view class="flex gap-3 mt-4">
  174. <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>
  175. <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>
  176. </view>
  177. </view>
  178. </view>
  179. </view>
  180. </template>
  181. <script setup lang="ts">
  182. import { ref, computed, onMounted, watch } from 'vue'
  183. import StatusBar from '../common/StatusBar.vue'
  184. import TopBar from '../common/TopBar.vue'
  185. import EmptyState from '../common/EmptyState.vue'
  186. import UniList from '../uni-list/uni-list.vue'
  187. import UniListItem from '../uni-list/uni-list-item.vue'
  188. import { getSchedulesByDate, createSchedule, updateSchedule, getScheduleList } from '@/api/schedule'
  189. import { getTeamSimpleList, getTeamMembers } from '@/api/team'
  190. import { getVehicleSimpleList } from '@/api/vehicle'
  191. withDefaults(
  192. defineProps<{
  193. showTopBar?: boolean
  194. showBack?: boolean
  195. showStatusBar?: boolean
  196. }>(),
  197. {
  198. showTopBar: true,
  199. showBack: true,
  200. showStatusBar: false,
  201. }
  202. )
  203. const loading = ref(false)
  204. const schedules = ref<any[]>([])
  205. const currentYear = ref(new Date().getFullYear())
  206. const currentMonth = ref(new Date().getMonth() + 1)
  207. const selectedDate = ref('')
  208. const showModal = ref(false)
  209. const isDetail = ref(false)
  210. const currentScheduleId = ref<number | undefined>(undefined)
  211. const teamOptions = ref<Array<{ value: number; label: string }>>([])
  212. const vehicleOptions = ref<Array<{ value: number; label: string }>>([])
  213. const form = ref({
  214. teamId: undefined as number | undefined,
  215. teamName: '',
  216. workDate: '',
  217. shiftType: 'month_plan' as 'month_plan' | 'emergency',
  218. vehicleIds: [] as number[],
  219. staffIds: '',
  220. status: 'idle',
  221. remark: '',
  222. })
  223. const teamIndex = computed(() => teamOptions.value.findIndex(t => t.value === form.value.teamId))
  224. const teamNameDisplay = computed(() => teamOptions.value.find(t => t.value === form.value.teamId)?.label)
  225. const weekDays = ['日', '一', '二', '三', '四', '五', '六']
  226. const calendarWeeks = computed(() => {
  227. const year = currentYear.value
  228. const month = currentMonth.value
  229. const firstDay = new Date(year, month - 1, 1)
  230. const lastDay = new Date(year, month, 0)
  231. const firstWeekDay = firstDay.getDay()
  232. const daysInMonth = lastDay.getDate()
  233. const weeks: any[] = []
  234. let currentWeek: any[] = []
  235. const prevMonthDays = new Date(year, month - 1, 0).getDate()
  236. for (let i = firstWeekDay - 1; i >= 0; i--) {
  237. currentWeek.push({
  238. day: prevMonthDays - i,
  239. isOtherMonth: true,
  240. isToday: false,
  241. hasSchedule: false,
  242. })
  243. }
  244. for (let day = 1; day <= daysInMonth; day++) {
  245. const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
  246. const isToday = dateStr === new Date().toISOString().split('T')[0]
  247. const hasSchedule = schedules.value.some(s => s.workDate === dateStr)
  248. currentWeek.push({
  249. day,
  250. isOtherMonth: false,
  251. isToday,
  252. hasSchedule,
  253. dateStr,
  254. })
  255. if (currentWeek.length === 7) {
  256. weeks.push(currentWeek)
  257. currentWeek = []
  258. }
  259. }
  260. if (currentWeek.length > 0) {
  261. for (let i = 1; currentWeek.length < 7; i++) {
  262. currentWeek.push({
  263. day: i,
  264. isOtherMonth: true,
  265. isToday: false,
  266. hasSchedule: false,
  267. })
  268. }
  269. weeks.push(currentWeek)
  270. }
  271. return weeks
  272. })
  273. const daySchedules = computed(() => {
  274. if (!selectedDate.value) return []
  275. return schedules.value.filter(s => s.workDate === selectedDate.value)
  276. })
  277. function changeMonth(delta: number) {
  278. let newMonth = currentMonth.value + delta
  279. if (newMonth > 12) {
  280. newMonth = 1
  281. currentYear.value++
  282. } else if (newMonth < 1) {
  283. newMonth = 12
  284. currentYear.value--
  285. }
  286. currentMonth.value = newMonth
  287. }
  288. function goToToday() {
  289. const now = new Date()
  290. currentYear.value = now.getFullYear()
  291. currentMonth.value = now.getMonth() + 1
  292. selectedDate.value = now.toISOString().split('T')[0]
  293. }
  294. function selectDate(date: any) {
  295. if (!date.isOtherMonth) {
  296. selectedDate.value = date.dateStr
  297. }
  298. }
  299. function isSelected(date: any): boolean {
  300. return date.dateStr === selectedDate.value
  301. }
  302. function getStatusText(status: string) {
  303. const map: Record<string, string> = {
  304. scheduled: '已排班',
  305. completed: '已完成',
  306. cancelled: '已取消',
  307. }
  308. return map[status] || status
  309. }
  310. function getScheduleIconBgClass(shiftType: string) {
  311. return shiftType === 'emergency' ? 'bg-red-50' : 'bg-blue-50'
  312. }
  313. function getScheduleIconClass(shiftType: string) {
  314. return shiftType === 'emergency' ? 'uniui-calendar-filled text-red-500' : 'uniui-calendar-filled text-blue-500'
  315. }
  316. function toggleVehicle(vehicleId: number) {
  317. const idx = form.value.vehicleIds.indexOf(vehicleId)
  318. if (idx >= 0) {
  319. form.value.vehicleIds.splice(idx, 1)
  320. } else {
  321. form.value.vehicleIds.push(vehicleId)
  322. }
  323. }
  324. async function addSchedule() {
  325. isDetail.value = false
  326. currentScheduleId.value = undefined
  327. resetForm()
  328. form.value.workDate = selectedDate.value
  329. showModal.value = true
  330. if (teamOptions.value.length === 0) {
  331. try {
  332. teamOptions.value = await getTeamSimpleList()
  333. } catch (e) {
  334. console.error('加载班组失败', e)
  335. }
  336. }
  337. if (vehicleOptions.value.length === 0) {
  338. try {
  339. vehicleOptions.value = await getVehicleSimpleList()
  340. } catch (e) {
  341. console.error('加载车辆失败', e)
  342. }
  343. }
  344. }
  345. function normalizeShiftType(shiftType: string | undefined): 'month_plan' | 'emergency' {
  346. if (!shiftType) return 'month_plan'
  347. if (shiftType === 'emergency' || shiftType.includes('应急')) return 'emergency'
  348. return 'month_plan'
  349. }
  350. function viewSchedule(schedule: any) {
  351. isDetail.value = true
  352. currentScheduleId.value = schedule.scheduleId
  353. form.value = {
  354. teamId: schedule.teamId,
  355. teamName: schedule.teamName || '',
  356. workDate: schedule.workDate || '',
  357. shiftType: normalizeShiftType(schedule.shiftType),
  358. vehicleIds: parseVehicleIds(schedule.vehicleIds),
  359. staffIds: schedule.staffIds || '',
  360. status: schedule.status || 'idle',
  361. remark: schedule.remark || '',
  362. }
  363. showModal.value = true
  364. if (teamOptions.value.length === 0) {
  365. getTeamSimpleList().then(res => { teamOptions.value = res }).catch(e => console.error('加载班组失败', e))
  366. }
  367. if (vehicleOptions.value.length === 0) {
  368. getVehicleSimpleList().then(res => { vehicleOptions.value = res }).catch(e => console.error('加载车辆失败', e))
  369. }
  370. }
  371. function parseVehicleIds(ids: string | number[] | undefined): number[] {
  372. if (!ids) return []
  373. if (Array.isArray(ids)) return ids.map(Number)
  374. return ids.split(',').map(s => Number(s.trim())).filter(n => !isNaN(n))
  375. }
  376. function closeModal() {
  377. showModal.value = false
  378. isDetail.value = false
  379. currentScheduleId.value = undefined
  380. }
  381. function resetForm() {
  382. form.value = {
  383. teamId: undefined,
  384. teamName: '',
  385. workDate: selectedDate.value,
  386. shiftType: 'month_plan',
  387. vehicleIds: [],
  388. staffIds: '',
  389. status: 'idle',
  390. remark: '',
  391. }
  392. }
  393. async function onTeamChange(teamId: number) {
  394. const team = teamOptions.value.find(t => t.value === teamId)
  395. form.value.teamName = team?.label || ''
  396. try {
  397. const members = await getTeamMembers(teamId)
  398. form.value.staffIds = members.map(m => m.staffId).join(',')
  399. } catch (e) {
  400. console.error('加载班组成员失败', e)
  401. }
  402. }
  403. async function submitSchedule() {
  404. if (!form.value.teamId) {
  405. uni.showToast({ title: '请选择班组', icon: 'none' })
  406. return
  407. }
  408. if (!form.value.workDate) {
  409. uni.showToast({ title: '请选择工作日期', icon: 'none' })
  410. return
  411. }
  412. try {
  413. const payload = {
  414. teamId: form.value.teamId,
  415. teamName: form.value.teamName,
  416. workDate: form.value.workDate,
  417. shiftType: form.value.shiftType,
  418. vehicleIds: form.value.vehicleIds.join(','),
  419. staffIds: form.value.staffIds,
  420. status: form.value.status,
  421. remark: form.value.remark,
  422. }
  423. if (isDetail.value && currentScheduleId.value) {
  424. await updateSchedule(currentScheduleId.value, payload)
  425. uni.showToast({ title: '保存成功', icon: 'success' })
  426. } else {
  427. await createSchedule(payload)
  428. uni.showToast({ title: '新增排班成功', icon: 'success' })
  429. }
  430. closeModal()
  431. resetForm()
  432. await fetchSchedules()
  433. } catch (e) {
  434. console.error(isDetail.value ? '保存排班失败' : '新增排班失败', e)
  435. }
  436. }
  437. async function fetchSchedules() {
  438. if (!selectedDate.value) return
  439. loading.value = true
  440. try {
  441. const [dayRes, monthRes] = await Promise.all([
  442. getSchedulesByDate(selectedDate.value),
  443. getScheduleList(currentYear.value, currentMonth.value)
  444. ])
  445. const dayList = dayRes || []
  446. const monthList = monthRes?.rows || monthRes || []
  447. const allSchedules = [...dayList]
  448. monthList.forEach((s: any) => {
  449. if (!allSchedules.some((x: any) => x.scheduleId === s.scheduleId)) {
  450. allSchedules.push(s)
  451. }
  452. })
  453. schedules.value = allSchedules
  454. } catch (error) {
  455. console.error('获取排班列表失败:', error)
  456. } finally {
  457. loading.value = false
  458. }
  459. }
  460. watch([selectedDate, currentMonth], fetchSchedules)
  461. onMounted(() => {
  462. const today = new Date().toISOString().split('T')[0]
  463. selectedDate.value = today
  464. fetchSchedules()
  465. })
  466. </script>
  467. <style scoped>
  468. .scale-down {
  469. transition: transform 0.15s ease;
  470. transform: scale(0.95);
  471. }
  472. </style>