技师排班解决方案
目录
- 数据库设计
- 后端实现
- 前端实现
设计指标
功能特点
- 技师可以设置默认工作规则
- 支持设置特殊日期(休假或特殊工作日)
- 自动生成30分钟时间槽
- 预约后自动预留间隔时间
- 完整的事务处理和并发控制
可扩展功能
- 节假日自动识别
- 批量设置特殊日期
- 工作规则模板
- 临时调整工作时间
- 规则生效时间设置
- 规则复制功能
技术特点
- 使用 Laravel + UniApp (Vue3) 技术栈
- TypeScript 类型支持
- 完整的错误处理
- 统一的请求封装
- 模块化的 API 管理
- 响应式界面设计
性能优化
- 数据库索引优化
- 事务处理
- 并发控制
- 缓存支持
- 批量操作优化
数据库设计
1. 数据表结构
CREATE TABLE technicians (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50) NOT NULL,
status TINYINT DEFAULT 1 '1:正常,0:停用',
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL
);
CREATE TABLE technician_work_rules (
id INT PRIMARY KEY AUTO_INCREMENT,
technician_id INT NOT NULL,
work_days VARCHAR(50) NOT NULL '工作日 例如:1,2,3,4,5 表示周一到周五',
start_time TIME NOT NULL DEFAULT '08:00' '默认上班时间',
end_time TIME NOT NULL DEFAULT '18:00' '默认下班时间',
is_active TINYINT DEFAULT 1 '是否启用',
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
UNIQUE KEY idx_technician (technician_id)
);
CREATE TABLE technician_special_dates (
id INT PRIMARY KEY AUTO_INCREMENT,
technician_id INT NOT NULL,
date DATE NOT NULL,
is_working TINYINT DEFAULT 0 '1:特殊工作日,0:休息日',
start_time TIME NULL '特殊工作日的上班时间',
end_time TIME NULL '特殊工作日的下班时间',
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
UNIQUE KEY idx_technician_date (technician_id, date)
);
CREATE TABLE technician_time_slots (
id INT PRIMARY KEY AUTO_INCREMENT,
technician_id INT NOT NULL,
date DATE NOT NULL,
start_time TIME NOT NULL,
end_time TIME NOT NULL,
status TINYINT DEFAULT 1 '1:可预约,0:已预约,2:预留间隔',
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
INDEX idx_technician_date (technician_id, date, status)
);
2. Laravel Migration 文件
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('technicians', function (Blueprint $table) {
$table->id();
$table->string('name', 50);
$table->tinyInteger('status')->default(1);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('technicians');
}
};
3. Model 定义
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
class Technician extends Model
{
protected $fillable = ['name', 'status'];
public function workRule(): HasOne
{
return $this->hasOne(TechnicianWorkRule::class);
}
public function specialDates(): HasMany
{
return $this->hasMany(TechnicianSpecialDate::class);
}
public function timeSlots(): HasMany
{
return $this->hasMany(TechnicianTimeSlot::class);
}
}
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TechnicianWorkRule extends Model
{
protected $fillable = [
'technician_id',
'work_days',
'start_time',
'end_time',
'is_active'
];
protected $casts = [
'is_active' => 'boolean',
'start_time' => 'datetime',
'end_time' => 'datetime'
];
public function technician(): BelongsTo
{
return $this->belongsTo(Technician::class);
}
public function getWorkDaysArrayAttribute(): array
{
return array_map('intval', explode(',', $this->work_days));
}
}
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TechnicianSpecialDate extends Model
{
protected $fillable = [
'technician_id',
'date',
'is_working',
'start_time',
'end_time'
];
protected $casts = [
'date' => 'date',
'is_working' => 'boolean',
'start_time' => 'datetime',
'end_time' => 'datetime'
];
public function technician(): BelongsTo
{
return $this->belongsTo(Technician::class);
}
}
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TechnicianTimeSlot extends Model
{
protected $fillable = [
'technician_id',
'date',
'start_time',
'end_time',
'status'
];
protected $casts = [
'date' => 'date',
'start_time' => 'datetime',
'end_time' => 'datetime'
];
public function technician(): BelongsTo
{
return $this->belongsTo(Technician::class);
}
}
class TechnicianSchedule extends Model
{
protected $casts = [
'work_time' => 'array',
'work_date' => 'date',
];
public function technician()
{
return $this->belongsTo(Technician::class);
}
public function workPlans()
{
return $this->hasMany(TechnicianWorkPlan::class, 'technician_id', 'technician_id')
->where('work_date', $this->work_date);
}
}
后端实现
1. Service 层实现
<?php
namespace App\Services;
use App\Models\TechnicianWorkRule;
use App\Models\TechnicianSpecialDate;
use App\Models\TechnicianTimeSlot;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use DB;
use Exception;
class TechnicianScheduleService
{
public function setWorkRule(
int $technicianId,
array $workDays,
string $startTime = '08:00',
string $endTime = '18:00'
): void {
try {
DB::beginTransaction();
TechnicianWorkRule::updateOrCreate(
['technician_id' => $technicianId],
[
'work_days' => implode(',', $workDays),
'start_time' => $startTime,
'end_time' => $endTime,
'is_active' => true
]
);
$this->generateFutureTimeSlots($technicianId);
DB::commit();
} catch (Exception $e) {
DB::rollBack();
throw $e;
}
}
public function generateTimeSlots(int $technicianId, string $date): void
{
$dateObj = Carbon::parse($date);
$dayOfWeek = $dateObj->dayOfWeek;
$workRule = TechnicianWorkRule::where('technician_id', $technicianId)
->where('is_active', true)
->first();
$specialDate = TechnicianSpecialDate::where('technician_id', $technicianId)
->where('date', $date)
->first();
$isWorkDay = false;
$startTime = null;
$endTime = null;
if ($specialDate) {
$isWorkDay = $specialDate->is_working;
$startTime = $specialDate->start_time;
$endTime = $specialDate->end_time;
} elseif ($workRule && in_array($dayOfWeek, $workRule->work_days_array)) {
$isWorkDay = true;
$startTime = $workRule->start_time;
$endTime = $workRule->end_time;
}
if (!$isWorkDay) {
return;
}
try {
DB::beginTransaction();
TechnicianTimeSlot::where('technician_id', $technicianId)
->where('date', $date)
->delete();
$currentTime = Carbon::parse($startTime);
$endDateTime = Carbon::parse($endTime);
while ($currentTime->copy()->addMinutes(30) <= $endDateTime) {
TechnicianTimeSlot::create([
'technician_id' => $technicianId,
'date' => $date,
'start_time' => $currentTime->format('H:i:s'),
'end_time' => $currentTime->copy()->addMinutes(30)->format('H:i:s'),
'status' => 1
]);
$currentTime->addMinutes(30);
}
DB::commit();
} catch (Exception $e) {
DB::rollBack();
throw $e;
}
}
}
2. Controller 层实现
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\TechnicianScheduleService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Exception;
class TechnicianScheduleController extends Controller
{
private TechnicianScheduleService $scheduleService;
public function __construct(TechnicianScheduleService $scheduleService)
{
$this->scheduleService = $scheduleService;
}
public function setWorkRule(Request $request): JsonResponse
{
$request->validate([
'technician_id' => 'required|exists:technicians,id',
'work_days' => 'required|array',
'work_days.*' => 'integer|between:0,6',
'start_time' => 'required|date_format:H:i',
'end_time' => 'required|date_format:H:i|after:start_time'
]);
try {
$this->scheduleService->setWorkRule(
$request->technician_id,
$request->work_days,
$request->start_time,
$request->end_time
);
return response()->json([
'message' => '工作规则设置成功'
]);
} catch (Exception $e) {
return response()->json([
'message' => '工作规则设置失败:' . $e->getMessage()
], 500);
}
}
public function setSpecialDate(Request $request): JsonResponse
{
$request->validate([
'technician_id' => 'required|exists:technicians,id',
'date' => 'required|date|after:today',
'is_working' => 'required|boolean',
'start_time' => 'required_if:is_working,true|date_format:H:i|nullable',
'end_time' => 'required_if:is_working,true|date_format:H:i|after:start_time|nullable'
]);
try {
$this->scheduleService->setSpecialDate(
$request->technician_id,
$request->date,
$request->is_working,
$request->start_time,
$request->end_time
);
return response()->json([
'message' => '特殊日期设置成功'
]);
} catch (Exception $e) {
return response()->json([
'message' => '特殊日期设置失败:' . $e->getMessage()
], 500);
}
}
public function getAvailableTimeSlots(Request $request): JsonResponse
{
$request->validate([
'technician_id' => 'required|exists:technicians,id',
'date' => 'required|date|after_or_equal:today',
'service_duration' => 'required|integer|min:30',
]);
try {
$schedule = TechnicianSchedule::where('technician_id', $request->technician_id)
->where('work_date', $request->date)
->first();
if (!$schedule) {
return response()->json([
'message' => '技师当天未排班',
'data' => []
]);
}
$bookedTimeSlots = TechnicianWorkPlan::where('technician_id', $request->technician_id)
->where('date', $request->date)
->where('status', '!=', 'cancelled')
->get(['plan_start_time', 'plan_end_time'])
->toArray();
$availableTimeSlots = $this->calculateAvailableTimeSlots(
$schedule->work_time,
$bookedTimeSlots,
$request->service_duration
);
return response()->json([
'message' => 'success',
'data' => $availableTimeSlots
]);
} catch (\Exception $e) {
return response()->json([
'message' => $e->getMessage(),
'data' => []
], 500);
}
}
private function calculateAvailableTimeSlots(array $workTime, array $bookedTimeSlots, int $serviceDuration): array
{
$availableSlots = [];
foreach ($workTime as $period) {
$startTime = strtotime($period['start']);
$endTime = strtotime($period['end']);
$currentSlot = $startTime;
while ($currentSlot + ($serviceDuration * 60) <= $endTime) {
$slotEnd = $currentSlot + ($serviceDuration * 60);
$isAvailable = true;
foreach ($bookedTimeSlots as $bookedSlot) {
$bookedStart = strtotime($bookedSlot['plan_start_time']);
$bookedEnd = strtotime($bookedSlot['plan_end_time']);
if ($currentSlot < $bookedEnd && $slotEnd > $bookedStart) {
$isAvailable = false;
break;
}
}
if ($isAvailable) {
$availableSlots[] = [
'start_time' => date('H:i', $currentSlot),
'end_time' => date('H:i', $slotEnd)
];
}
$currentSlot += 1800;
}
}
return $availableSlots;
}
}
3. 路由定义
Route::prefix('technician')->group(function () {
Route::post('work-rule', [TechnicianScheduleController::class, 'setWorkRule']);
Route::post('special-date', [TechnicianScheduleController::class, 'setSpecialDate']);
Route::get('time-slots', [TechnicianScheduleController::class, 'getAvailableTimeSlots']);
Route::post('book', [TechnicianScheduleController::class, 'bookTimeSlot']);
});
前端实现 (UniApp Vue3)
1. 工作规则设置页面
<template>
<view class="schedule-setting">
<uni-section title="工作日设置" type="line">
<uni-list>
<uni-list-item v-for="day in weekDays" :key="day.value"
:title="day.label"
:switchChecked="workDays.includes(day.value)"
@switch-change="(e) => toggleWorkDay(day.value, e.value)"
showSwitch
/>
</uni-list>
</uni-section>
<uni-section title="工作时间设置" type="line">
<uni-list>
<uni-list-item title="上班时间" showArrow @click="showStartTimePicker = true">
<template v-slot:footer>
<text>{{ startTime }}</text>
</template>
</uni-list-item>
<uni-list-item title="下班时间" showArrow @click="showEndTimePicker = true">
<template v-slot:footer>
<text>{{ endTime }}</text>
</template>
</uni-list-item>
</uni-list>
</uni-section>
<view class="button-group">
<button class="primary-button" @click="saveWorkRule">保存设置</button>
</view>
<uni-section title="特殊日期设置" type="line">
<button class="secondary-button" @click="showCalendar = true">
设置休息日/特殊工作日
</button>
</uni-section>
<uni-datetime-picker
v-model="showStartTimePicker"
type="time"
:value="startTime"
@confirm="onStartTimeChange"
/>
<uni-datetime-picker
v-model="showEndTimePicker"
type="time"
:value="endTime"
@confirm="onEndTimeChange"
/>
<uni-calendar
v-model="showCalendar"
:insert="false"
@confirm="onSelectSpecialDate"
:start-date="startDate"
:end-date="endDate"
/>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
const weekDays = [
{ label: '周一', value: 1 },
{ label: '周二', value: 2 },
{ label: '周三', value: 3 },
{ label: '周四', value: 4 },
{ label: '周五', value: 5 },
{ label: '周六', value: 6 },
{ label: '周日', value: 0 }
]
const workDays = ref([1, 2, 3, 4, 5])
const startTime = ref('08:00')
const endTime = ref('18:00')
const showStartTimePicker = ref(false)
const showEndTimePicker = ref(false)
const showCalendar = ref(false)
const startDate = new Date()
const endDate = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000)
const getCurrentSettings = async () => {
try {
const res = await uni.request({
url: '/api/technician/work-rule',
method: 'GET',
data: {
technician_id: getApp().globalData.technicianId
}
})
if (res.statusCode === 200) {
const { work_days, start_time, end_time } = res.data.data
workDays.value = work_days.split(',').map(Number)
startTime.value = start_time
endTime.value = end_time
}
} catch (error) {
uni.showToast({
title: '获取设置失败',
icon: 'none'
})
}
}
const toggleWorkDay = (day, checked) => {
if (checked) {
workDays.value.push(day)
} else {
const index = workDays.value.indexOf(day)
if (index > -1) {
workDays.value.splice(index, 1)
}
}
}
const onStartTimeChange = (time) => {
startTime.value = time
}
const onEndTimeChange = (time) => {
endTime.value = time
}
const saveWorkRule = async () => {
try {
const res = await uni.request({
url: '/api/technician/work-rule',
method: 'POST',
data: {
technician_id: getApp().globalData.technicianId,
work_days: workDays.value,
start_time: startTime.value,
end_time: endTime.value
}
})
if (res.statusCode === 200) {
uni.showToast({
title: '设置成功',
icon: 'success'
})
} else {
throw new Error(res.data.message)
}
} catch (error) {
uni.showToast({
title: error.message || '设置失败',
icon: 'none'
})
}
}
onLoad(() => {
getCurrentSettings()
})
</script>
<style lang="scss">
.schedule-setting {
padding: 20rpx;
.button-group {
margin: 30rpx 0;
}
.primary-button {
background-color: #007AFF;
color: #fff;
border-radius: 10rpx;
padding: 20rpx;
text-align: center;
}
.secondary-button {
background-color: #F8F8F8;
color: #333;
border: 1px solid #DDDDDD;
border-radius: 10rpx;
padding: 20rpx;
text-align: center;
margin-top: 20rpx;
}
}
</style>
2. 特殊日期设置页面
<template>
<view class="special-date">
<uni-forms ref="form" :modelValue="formData">
<uni-forms-item label="日期">
<text>{{ date }}</text>
</uni-forms-item>
<uni-forms-item label="是否工作">
<switch :checked="formData.is_working" @change="onWorkingChange" />
</uni-forms-item>
<template v-if="formData.is_working">
<uni-forms-item label="上班时间">
<view class="time-picker" @click="showStartTimePicker = true">
{{ formData.start_time || '请选择' }}
</view>
</uni-forms-item>
<uni-forms-item label="下班时间">
<view class="time-picker" @click="showEndTimePicker = true">
{{ formData.end_time || '请选择' }}
</view>
</uni-forms-item>
</template>
</uni-forms>
<view class="button-group">
<button class="primary-button" @click="saveSpecialDate">保存设置</button>
</view>
<uni-datetime-picker
v-model="showStartTimePicker"
type="time"
:value="formData.start_time"
@confirm="onStartTimeChange"
/>
<uni-datetime-picker
v-model="showEndTimePicker"
type="time"
:value="formData.end_time"
@confirm="onEndTimeChange"
/>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
const date = ref('')
const formData = ref({
is_working: false,
start_time: '',
end_time: ''
})
const showStartTimePicker = ref(false)
const showEndTimePicker = ref(false)
const getSpecialDateSettings = async (date) => {
try {
const res = await uni.request({
url: '/api/technician/special-date',
method: 'GET',
data: {
technician_id: getApp().globalData.technicianId,
date: date
}
})
if (res.statusCode === 200) {
formData.value = res.data.data || {
is_working: false,
start_time: '',
end_time: ''
}
}
} catch (error) {
uni.showToast({
title: '获取设置失败',
icon: 'none'
})
}
}
const onWorkingChange = (e) => {
formData.value.is_working = e.detail.value
}
const onStartTimeChange = (time) => {
formData.value.start_time = time
}
const onEndTimeChange = (time) => {
formData.value.end_time = time
}
const saveSpecialDate = async () => {
try {
const res = await uni.request({
url: '/api/technician/special-date',
method: 'POST',
data: {
technician_id: getApp().globalData.technicianId,
date: date.value,
...formData.value
}
})
if (res.statusCode === 200) {
uni.showToast({
title: '设置成功',
icon: 'success'
})
setTimeout(() => {
uni.navigateBack()
}, 1500)
} else {
throw new Error(res.data.message)
}
} catch (error) {
uni.showToast({
title: error.message || '设置失败',
icon: 'none'
})
}
}
onLoad((options) => {
date.value = options.date
getSpecialDateSettings(options.date)
})
</script>
<style lang="scss">
.special-date {
padding: 20rpx;
.time-picker {
border: 1px solid #DDDDDD;
padding: 20rpx;
border-radius: 10rpx;
}
.button-group {
margin: 30rpx 0;
}
.primary-button {
background-color: #007AFF;
color: #fff;
border-radius: 10rpx;
padding: 20rpx;
text-align: center;
}
}
</style>
3. 获取技师可用时间例子
<template>
<div>
<van-datetime-picker
v-model="selectedDate"
type="date"
:min-date="minDate"
:max-date="maxDate"
@confirm="onDateSelected"
/>
<van-picker
v-if="timeSlots.length"
:columns="timeSlots"
@confirm="onTimeSelected"
/>
</div>
</template>
<script setup>
import { ref } from 'vue'
import axios from 'axios'
import { showToast } from 'vant'
const selectedDate = ref(new Date())
const minDate = new Date()
const maxDate = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
const timeSlots = ref([])
const getAvailableTimeSlots = async (date) => {
try {
const response = await axios.get('/api/technician/available-time-slots', {
params: {
technician_id: 1,
date: date,
service_duration: 60
}
})
timeSlots.value = response.data.data.map(slot =>
`${slot.start_time}-${slot.end_time}`
)
} catch (error) {
showToast(error.response?.data?.message || '获取可用时间失败')
}
}
const onDateSelected = (value) => {
const formatDate = value.toISOString().split('T')[0]
getAvailableTimeSlots(formatDate)
}
const onTimeSelected = (value) => {
console.log('Selected time slot:', value)
}
</script>
4. API 请求封装
import { baseURL } from '@/config'
interface RequestOptions extends UniApp.RequestOptions {
loading?: boolean;
}
const request = (options: RequestOptions) => {
const { loading = true } = options
if (loading) {
uni.showLoading({
title: '加载中'
})
}
return new Promise((resolve, reject) => {
uni.request({
...options,
url: baseURL + options.url,
header: {
...options.header,
'Authorization': `Bearer ${uni.getStorageSync('token')}`
},
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data)
} else {
reject(res.data)
}
},
fail: (err) => {
reject(err)
},
complete: () => {
if (loading) {
uni.hideLoading()
}
}
})
})
}
export default request
5. API 接口定义
import request from '@/utils/request'
export const getWorkRule = (technicianId: number) => {
return request({
url: '/technician/work-rule',
method: 'GET',
data: { technician_id: technicianId }
})
}
export const setWorkRule = (data: {
technician_id: number;
work_days: number[];
start_time: string;
end_time: string;
}) => {
return request({
url: '/technician/work-rule',
method: 'POST',
data
})
}
export const getSpecialDate = (technicianId: number, date: string) => {
return request({
url: '/technician/special-date',
method: 'GET',
data: {
technician_id: technicianId,
date
}
})
}
export const setSpecialDate = (data: {
technician_id: number;
date: string;
is_working: boolean;
start_time?: string;
end_time?: string;
}) => {
return request({
url: '/technician/special-date',
method: 'POST',
data
})
}
export const getTimeSlots = (technicianId: number, date: string) => {
return request({
url: '/technician/time-slots',
method: 'GET',
data: {
technician_id: technicianId,
date
}
})
}
6. 页面配置
{
"pages": [
{
"path": "pages/technician/schedule/index",
"style": {
"navigationBarTitleText": "排班设置"
}
},
{
"path": "pages/technician/schedule/special-date",
"style": {
"navigationBarTitleText": "特殊日期设置"
}
}
]
}