新增到课签到时间窗口与迟到签到时间窗口配置,优化教练评分机制(未测试)

This commit is contained in:
2026-07-26 16:10:23 +08:00
parent 86b7555943
commit c2f11727fe
31 changed files with 2069 additions and 138 deletions
+7 -7
View File
@@ -118,22 +118,22 @@ WHERE NOT EXISTS (SELECT 1 FROM course_type_label WHERE type_id = 103 AND label_
-- 晨间瑜伽 | A教室 | 明天 08:00-09:00 | 教练100
INSERT INTO group_course (id, course_name, coach_id, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by, update_by, created_at, updated_at)
SELECT 100, '晨间瑜伽', 100, 100, '2026-07-23 08:00:00'::TIMESTAMP, '2026-07-23 09:00:00'::TIMESTAMP, 20, 5, '0', 'A教室', '/static/course/e2e_yoga.jpg', 'E2E测试-晨间瑜伽课程,温和唤醒身体', 88.00, 'system', 'system', NOW(), NOW()
SELECT 100, '晨间瑜伽', 100, 100, '2026-07-27 08:00:00'::TIMESTAMP, '2026-07-27 09:00:00'::TIMESTAMP, 20, 5, '0', 'A教室', '/static/course/e2e_yoga.jpg', 'E2E测试-晨间瑜伽课程,温和唤醒身体', 88.00, 'system', 'system', NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM group_course WHERE id = 100);
-- 动感燃脂 | 单车房 | 后天 19:00-20:00 | 教练101
INSERT INTO group_course (id, course_name, coach_id, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by, update_by, created_at, updated_at)
SELECT 101, '动感燃脂', 101, 101, '2026-07-24 19:00:00'::TIMESTAMP, '2026-07-24 20:00:00'::TIMESTAMP, 25, 10, '0', '单车房', '/static/course/e2e_spin.jpg', 'E2E测试-动感单车燃脂课程', 68.00, 'system', 'system', NOW(), NOW()
SELECT 101, '动感燃脂', 101, 101, '2026-07-28 19:00:00'::TIMESTAMP, '2026-07-28 20:00:00'::TIMESTAMP, 25, 10, '0', '单车房', '/static/course/e2e_spin.jpg', 'E2E测试-动感单车燃脂课程', 68.00, 'system', 'system', NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM group_course WHERE id = 101);
-- 有氧搏击 | B教室 | 大后天 10:00-11:00 | 教练100
INSERT INTO group_course (id, course_name, coach_id, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by, update_by, created_at, updated_at)
SELECT 102, '有氧搏击', 100, 102, '2026-07-25 10:00:00'::TIMESTAMP, '2026-07-25 11:00:00'::TIMESTAMP, 20, 8, '0', 'B教室', '/static/course/e2e_boxing.jpg', 'E2E测试-有氧搏击操课程', 68.00, 'system', 'system', NOW(), NOW()
SELECT 102, '有氧搏击', 100, 102, '2026-07-29 10:00:00'::TIMESTAMP, '2026-07-29 11:00:00'::TIMESTAMP, 20, 8, '0', 'B教室', '/static/course/e2e_boxing.jpg', 'E2E测试-有氧搏击操课程', 68.00, 'system', 'system', NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM group_course WHERE id = 102);
-- 普拉提入门 | 瑜伽房 | 4天后 14:00-15:00 | 教练101
INSERT INTO group_course (id, course_name, coach_id, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by, update_by, created_at, updated_at)
SELECT 103, '普拉提入门', 101, 103, '2026-07-26 14:00:00'::TIMESTAMP, '2026-07-26 15:00:00'::TIMESTAMP, 15, 3, '0', '瑜伽房', '/static/course/e2e_pilates.jpg', 'E2E测试-普拉提入门课程,适合初学者', 108.00, 'system', 'system', NOW(), NOW()
SELECT 103, '普拉提入门', 101, 103, '2026-07-30 14:00:00'::TIMESTAMP, '2026-07-30 15:00:00'::TIMESTAMP, 15, 3, '0', '瑜伽房', '/static/course/e2e_pilates.jpg', 'E2E测试-普拉提入门课程,适合初学者', 108.00, 'system', 'system', NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM group_course WHERE id = 103);
-- ============================================
@@ -158,9 +158,9 @@ ON CONFLICT (member_no) DO UPDATE SET
INSERT INTO sign_in_record (id, member_id, sign_in_time, sign_in_type, sign_in_status, source, created_at, updated_at)
VALUES
(100, 100, '2026-07-22 08:30:00'::TIMESTAMP, 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', NOW(), NOW()),
(101, 101, '2026-07-22 09:00:00'::TIMESTAMP, 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', NOW(), NOW()),
(102, 100, '2026-07-22 18:00:00'::TIMESTAMP, 'MANUAL', 'SUCCESS', 'PC_BACKEND', NOW(), NOW())
(100, 100, '2026-07-26 08:30:00'::TIMESTAMP, 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', NOW(), NOW()),
(101, 101, '2026-07-26 09:00:00'::TIMESTAMP, 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', NOW(), NOW()),
(102, 100, '2026-07-26 18:00:00'::TIMESTAMP, 'MANUAL', 'SUCCESS', 'PC_BACKEND', NOW(), NOW())
ON CONFLICT (id) DO NOTHING;
-- ============================================
+37
View File
@@ -42,6 +42,21 @@ export interface ViolationRecord {
created_at: string
}
export interface CoachTimeRule {
id?: number
minDuration?: number | null
maxDuration?: number | null
normalWindow: number
lateWindow: number
endGrace: number
isDefault?: boolean
sortOrder?: number
status?: string
remark?: string
createdAt?: string
updatedAt?: string
}
export const coachApi = {
/** 获取所有教练列表 */
getAll: () =>
@@ -71,3 +86,25 @@ export const coachApi = {
getViolations: (id: string) =>
request.get<ViolationRecord[]>(`/coach/${id}/violations`),
}
export const coachTimeRuleApi = {
/** 获取所有时间规则 */
getAll: () =>
request.get<CoachTimeRule[]>('/coach/time-rules'),
/** 获取单条规则 */
getById: (id: number) =>
request.get<CoachTimeRule>(`/coach/time-rules/${id}`),
/** 创建规则 */
create: (data: CoachTimeRule) =>
request.post<{ message: string; id: number }>('/coach/time-rules', data),
/** 更新规则 */
update: (id: number, data: CoachTimeRule) =>
request.put<{ message: string }>(`/coach/time-rules/${id}`, data),
/** 删除规则 */
delete: (id: number) =>
request.delete<{ message: string }>(`/coach/time-rules/${id}`),
}
+6
View File
@@ -46,6 +46,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/system/CoachManagement.vue'),
meta: { title: '教练管理' }
},
{
path: 'coach/time-rules',
name: 'CoachTimeRuleManagement',
component: () => import('@/views/coach/CoachTimeRuleManagement.vue'),
meta: { title: '时间规则配置' }
},
{
path: 'roles',
name: 'RoleManagement',
+2
View File
@@ -48,6 +48,7 @@ function transformMenuData(backendMenus: BackendMenuItem[]): MenuItem[] {
'statistics/dashboard/index': '/statistics',
'banner/index': '/banners',
'brand/index': '/brand',
'coach/time-rule/index': '/coach/time-rules',
}
const filteredMenus = backendMenus.filter(menu => menu.menuType !== 'F')
@@ -113,6 +114,7 @@ function getMenuIcon(menuName: string): string {
'数据统计': 'DataAnalysis',
'轮播图管理': 'Picture',
'品牌定制': 'Brush',
'时间规则配置': 'Timer',
}
return iconMap[menuName] || 'Document'
}
+4
View File
@@ -36,6 +36,10 @@ const permissionMapping: PermissionMapping = {
'POST /brand': 'brand:config:list',
'PUT /brand': 'brand:config:list',
'DELETE /brand': 'brand:config:list',
'GET /coach/time-rules': 'system:coach:timeRule:list',
'POST /coach/time-rules': 'system:coach:timeRule:add',
'PUT /coach/time-rules': 'system:coach:timeRule:edit',
'DELETE /coach/time-rules': 'system:coach:timeRule:remove',
}
export function checkApiPermission(method: string, url: string): boolean {
@@ -0,0 +1,366 @@
<template>
<div class="time-rule-management">
<el-card>
<template #header>
<div class="card-title">
<span>教练时间规则配置</span>
<el-button type="primary" @click="handleAdd">
新增规则
</el-button>
</div>
</template>
<el-alert
type="info"
:closable="false"
style="margin-bottom: 16px"
>
<template #title>
规则匹配逻辑系统根据课程时长分钟自动匹配区间最精确的规则若课程时长在 [时长下限, 时长上限] 范围内则应用该规则的阈值默认规则兜底规则不能设置时长区间且全局仅允许一个默认规则设为默认时将自动取消其他默认规则未匹配到任何规则时使用默认规则或系统硬编码兜底值
</template>
</el-alert>
<el-table
v-loading="loading"
:data="dataSource"
style="width: 100%"
empty-text="暂无规则配置将使用系统默认值"
>
<el-table-column prop="id" label="ID" width="60" />
<el-table-column label="时长下限(分)" width="110">
<template #default="{ row }">
{{ row.minDuration ?? '无限制' }}
</template>
</el-table-column>
<el-table-column label="时长上限(分)" width="110">
<template #default="{ row }">
{{ row.maxDuration ?? '无限制' }}
</template>
</el-table-column>
<el-table-column label="正常开课窗口(分)" width="140">
<template #default="{ row }">
<el-tag type="success" effect="plain">{{ row.normalWindow }}</el-tag>
</template>
</el-table-column>
<el-table-column label="迟到/缺席窗口(分)" width="150">
<template #default="{ row }">
<el-tag type="warning" effect="plain">{{ row.lateWindow }}</el-tag>
</template>
</el-table-column>
<el-table-column label="结课宽限期(分)" width="120">
<template #default="{ row }">
<el-tag type="info" effect="plain">{{ row.endGrace }}</el-tag>
</template>
</el-table-column>
<el-table-column label="默认" width="70">
<template #default="{ row }">
<el-tag v-if="row.isDefault" type="primary" size="small">默认</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="状态" width="70">
<template #default="{ row }">
<el-tag
:type="row.status === '1' ? 'success' : 'danger'"
effect="dark"
>
{{ row.status === '1' ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip />
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleEdit(row)">
编辑
</el-button>
<el-button type="danger" link size="small" @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog
v-model="modalVisible"
:title="modalTitle"
width="550px"
@closed="resetForm"
>
<el-form
ref="formRef"
:model="formState"
:rules="rules"
label-width="140px"
>
<el-form-item label="时长下限(分钟)" prop="minDuration">
<el-input-number
v-model="formState.minDuration"
:min="1"
:max="1440"
:disabled="formState.isDefault"
placeholder="留空表示无下限"
style="width: 100%"
controls-position="right"
/>
<div class="form-tip">{{ formState.isDefault ? '默认规则为兜底规则,不限制时长区间' : '课程时长 >= 此值时才应用本规则' }}</div>
</el-form-item>
<el-form-item label="时长上限(分钟)" prop="maxDuration">
<el-input-number
v-model="formState.maxDuration"
:min="1"
:max="1440"
:disabled="formState.isDefault"
placeholder="留空表示无上限"
style="width: 100%"
controls-position="right"
/>
<div class="form-tip">{{ formState.isDefault ? '默认规则为兜底规则,不限制时长区间' : '课程时长 <= 此值时才应用本规则' }}</div>
</el-form-item>
<el-form-item label="正常开课窗口(分钟)" prop="normalWindow">
<el-input-number
v-model="formState.normalWindow"
:min="1"
:max="1440"
style="width: 100%"
controls-position="right"
/>
<div class="form-tip">开课时间后此分钟内开课视为正常</div>
</el-form-item>
<el-form-item label="迟到/缺席窗口(分钟)" prop="lateWindow">
<el-input-number
v-model="formState.lateWindow"
:min="1"
:max="1440"
style="width: 100%"
controls-position="right"
/>
<div class="form-tip">超过正常窗口但在此时间内视为迟到超过此值视为缺席</div>
</el-form-item>
<el-form-item label="结课宽限期(分钟)" prop="endGrace">
<el-input-number
v-model="formState.endGrace"
:min="0"
:max="1440"
style="width: 100%"
controls-position="right"
/>
<div class="form-tip">课程结束后此分钟内允许手动结课超时自动结课</div>
</el-form-item>
<el-form-item label="设为默认规则">
<el-switch
v-model="formState.isDefault"
active-text=""
inactive-text=""
@change="onDefaultChange"
/>
<div class="form-tip form-tip-warn">
设为此规则的默认规则前旧默认规则会自动取消默认规则不能设置时长区间
</div>
</el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="formState.status">
<el-radio value="1">启用</el-radio>
<el-radio value="0">停用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="formState.remark"
type="textarea"
:rows="2"
placeholder="规则说明(选填)"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="modalVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleModalOk">
确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { coachTimeRuleApi, type CoachTimeRule } from '@/api/coach.api'
const loading = ref(false)
const submitting = ref(false)
const dataSource = ref<CoachTimeRule[]>([])
const modalVisible = ref(false)
const modalTitle = ref('')
const formRef = ref<FormInstance>()
const defaultForm: CoachTimeRule = {
minDuration: undefined,
maxDuration: undefined,
normalWindow: 10,
lateWindow: 30,
endGrace: 10,
isDefault: false,
status: '1',
remark: '',
}
const formState = reactive<CoachTimeRule>({ ...defaultForm })
const validateWindowOrder = (_rule: any, _value: any, callback: any) => {
if (formState.normalWindow != null && formState.lateWindow != null) {
if (formState.lateWindow < formState.normalWindow) {
callback(new Error('迟到窗口必须 >= 正常开课窗口'))
return
}
}
callback()
}
const validateDurationRange = (_rule: any, _value: any, callback: any) => {
if (formState.minDuration != null && formState.maxDuration != null) {
if (formState.maxDuration < formState.minDuration) {
callback(new Error('时长上限必须 >= 时长下限'))
return
}
}
callback()
}
const rules: FormRules = {
normalWindow: [
{ required: true, message: '请输入正常开课窗口', trigger: 'blur' },
{ validator: validateWindowOrder, trigger: 'change' },
],
lateWindow: [
{ required: true, message: '请输入迟到/缺席窗口', trigger: 'blur' },
{ validator: validateWindowOrder, trigger: 'change' },
],
endGrace: [
{ required: true, message: '请输入结课宽限期', trigger: 'blur' },
],
minDuration: [
{ validator: validateDurationRange, trigger: 'change' },
],
maxDuration: [
{ validator: validateDurationRange, trigger: 'change' },
],
}
const resetForm = () => {
Object.assign(formState, { ...defaultForm, id: undefined })
formRef.value?.resetFields()
}
const fetchData = async () => {
loading.value = true
try {
dataSource.value = await coachTimeRuleApi.getAll()
} catch {
ElMessage.error('获取规则列表失败')
} finally {
loading.value = false
}
}
const handleAdd = () => {
modalTitle.value = '新增规则'
resetForm()
modalVisible.value = true
}
const handleEdit = (row: CoachTimeRule) => {
modalTitle.value = '编辑规则'
Object.assign(formState, {
id: row.id,
minDuration: row.minDuration ?? undefined,
maxDuration: row.maxDuration ?? undefined,
normalWindow: row.normalWindow,
lateWindow: row.lateWindow,
endGrace: row.endGrace,
isDefault: row.isDefault ?? false,
status: row.status ?? '1',
remark: row.remark ?? '',
})
modalVisible.value = true
}
const onDefaultChange = (val: boolean) => {
if (val) {
formState.minDuration = undefined
formState.maxDuration = undefined
}
}
const handleDelete = async (row: CoachTimeRule) => {
try {
await ElMessageBox.confirm('确定要删除该规则吗?删除后立即生效。', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
await coachTimeRuleApi.delete(row.id!)
ElMessage.success('删除成功,已立即生效')
fetchData()
} catch {
// 用户取消
}
}
const handleModalOk = async () => {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
const payload: CoachTimeRule = {
minDuration: formState.minDuration ?? null,
maxDuration: formState.maxDuration ?? null,
normalWindow: formState.normalWindow,
lateWindow: formState.lateWindow,
endGrace: formState.endGrace,
isDefault: formState.isDefault,
sortOrder: 0,
status: formState.status,
remark: formState.remark,
}
if (formState.id) {
await coachTimeRuleApi.update(formState.id, payload)
ElMessage.success('更新成功,已立即生效')
} else {
await coachTimeRuleApi.create(payload)
ElMessage.success('创建成功,已立即生效')
}
modalVisible.value = false
fetchData()
} catch (err: any) {
const msg = err?.response?.data?.error || '操作失败'
ElMessage.error(msg)
} finally {
submitting.value = false
}
}
onMounted(() => fetchData())
</script>
<style scoped lang="css">
.time-rule-management .card-title {
display: flex;
justify-content: space-between;
align-items: center;
}
.form-tip {
font-size: 12px;
color: #909399;
margin-top: 4px;
line-height: 1.4;
}
.form-tip-warn {
color: #e6a23c;
}
</style>