Version: 0.9.42.dev.260424

后端:
1. 新增课表图片识别接口,支持上传截图后返回“可编辑草稿”(success / partial / reject),并补齐大图、空图、格式不支持、识别能力未配置等错误分支。
2. 课表识别服务接入多模态 Responses 链路,完善图片请求归一化与安全校验(大小、MIME、内容探测),并对识别结果做结构化清洗、强/弱约束校验、告警去重与默认文案兜底。
3. 新增 Ark Responses 统一客户端抽象,支持文本+图片输入、JSON对象输出、usage统计透传与不完整输出识别;同时补齐模型返回 finish_reason 透传,便于定位截断问题。
4. 启动阶段增加课表识图模型与参数注入(模型名、最大图片字节、最大输出token),并将配置示例收敛为“仅保留当前代码实际读取项”。

前端:
5. 课表中心新增“导入课表”完整闭环:上传图片识别、草稿编辑校对、正式导入落库;并新增对应 API 与类型定义。
6. 导入弹窗支持识别中止、全局告警与行级告警展示、低置信度提示、行内编辑、手动新增、删除、拖拽排序、本地校验与提交前二次确认。
7. 正式导入前将草稿按“课程名+地点+是否允许嵌入”聚合为导入结构,并统一携带幂等键请求头,降低重复提交风险。
8. 周课表画板修复跨节次事件遮挡导致的网格错位问题,改进“完全遮挡/部分遮挡”渲染判定与 grid 行定位。
9. 助手流式区域优化“思考中”指示逻辑与样式,避免已有正文时仍展示回答中占位;同时补充全局组件视觉统一(弹窗/按钮)样式。

仓库:
10. 新增课表图片识别前端对接说明文档,补充主动优化能力 PRD 讨论稿,并在协作规范中新增“实现 Eino 新能力前需先查官方文档”的约束。
This commit is contained in:
Losita
2026-04-24 23:33:43 +08:00
parent 8daae62812
commit 04b5836b39
23 changed files with 3539 additions and 171 deletions

View File

@@ -0,0 +1,927 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { parseCourseImage, importCourses } from '@/api/scheduleCenter'
import type { CourseDraftRow, CourseImportPayload } from '@/types/schedule'
const props = defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}>()
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
// 状态管理
const step = ref<'upload' | 'edit'>('upload')
const imageFile = ref<File | null>(null)
const imagePreview = ref('')
const parseLoading = ref(false)
const importLoading = ref(false)
const abortController = ref<AbortController | null>(null)
const draftStatus = ref<'success' | 'partial' | 'reject' | null>(null)
const draftMessage = ref('')
const warnings = ref<string[]>([])
const rows = ref<CourseDraftRow[]>([])
// 新增课程相关的状态
const addDialogVisible = ref(false)
const newRowTemplate = (): CourseDraftRow => ({
row_id: 'manual_' + Date.now(),
course_name: '',
location: '',
start_week: 1,
end_week: 16,
day_of_week: 1,
start_section: 1,
end_section: 2,
week_type: 'all',
is_allow_tasks: false,
confidence: 1,
raw_text: '手动添加',
row_warnings: []
})
const newRowData = ref<CourseDraftRow>(newRowTemplate())
// 拖拽相关状态
const draggingIndex = ref<number | null>(null)
// 模拟示例图 URL
const exampleImageUrl = 'https://dl2.lecspace.com/SmartFlow-Agent/%E8%AF%BE%E8%A1%A8%E4%B8%8A%E4%BC%A0%E7%A4%BA%E4%BE%8B%E5%9B%BE%E7%89%87.png'
const handleFileChange = (e: Event) => {
const input = e.target as HTMLInputElement
if (input.files && input.files[0]) {
const file = input.files[0]
// 简单校验格式
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) {
ElMessage.warning('仅支持 JPG/PNG/WEBP 格式的图片')
return
}
imageFile.value = file
imagePreview.value = URL.createObjectURL(file)
}
}
const startRecognition = async () => {
if (!imageFile.value) return
parseLoading.value = true
abortController.value = new AbortController()
try {
const data = await parseCourseImage(imageFile.value, abortController.value.signal)
if (data.draft_status === 'reject') {
await ElMessageBox.alert(data.message || '图片识别失败,请重新上传清晰的课表图片。', '识别被拒绝', {
type: 'error',
confirmButtonText: '我知道了'
})
return
}
draftStatus.value = data.draft_status
draftMessage.value = data.message
warnings.value = data.warnings
// 对识别出的行进行排序:先按课程名聚类,再按起始周从小到大排列
rows.value = [...data.rows]
sortRows()
step.value = 'edit'
} catch (error: any) {
if (error.message !== '识别已取消') {
ElMessage.error(error.message || '识别过程中出现错误')
}
} finally {
parseLoading.value = false
abortController.value = null
}
}
const stopRecognition = () => {
if (abortController.value) {
abortController.value.abort()
ElMessage.info('识别已手动停止')
}
}
const sortRows = () => {
rows.value.sort((a, b) => {
const nameCompare = (a.course_name || '').localeCompare(b.course_name || '')
if (nameCompare !== 0) return nameCompare
return (a.start_week || 0) - (b.start_week || 0)
})
}
const handleOpenAddDialog = () => {
newRowData.value = newRowTemplate()
addDialogVisible.value = true
}
const handleConfirmAddRow = () => {
if (!newRowData.value.course_name.trim()) {
ElMessage.warning('课程名不能为空')
return
}
rows.value.push({ ...newRowData.value })
sortRows()
addDialogVisible.value = false
ElMessage.success('课程已添加并自动排序')
}
const handleRemoveRow = async (index: number) => {
try {
await ElMessageBox.confirm('确定要删除这一行课程安排吗?', '确认删除', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
rows.value.splice(index, 1)
ElMessage.success('已删除')
} catch {
// 用户取消删除
}
}
// 拖拽逻辑实现
const onDragStart = (index: number) => {
draggingIndex.value = index
}
const onDragOver = (e: DragEvent) => {
e.preventDefault() // 允许放置
}
const onDrop = (index: number) => {
if (draggingIndex.value === null || draggingIndex.value === index) return
const draggedRow = rows.value[draggingIndex.value]
rows.value.splice(draggingIndex.value, 1)
rows.value.splice(index, 0, draggedRow)
draggingIndex.value = null
}
const reset = () => {
step.value = 'upload'
imageFile.value = null
imagePreview.value = ''
rows.value = []
draftStatus.value = null
warnings.value = []
}
// 校验与提交逻辑
const validateRows = () => {
const errors: string[] = []
rows.value.forEach((row, index) => {
if (!row.course_name.trim()) errors.push(`${index + 1} 行:课程名不能为空`)
if (row.start_week == null || row.end_week == null || row.start_week < 1 || row.end_week > 24 || row.start_week > row.end_week) {
errors.push(`${index + 1} 行:周次范围非法 (1-24)`)
}
if (row.day_of_week == null || row.day_of_week < 1 || row.day_of_week > 7) {
errors.push(`${index + 1} 行:星期选择非法`)
}
if (row.start_section == null || row.end_section == null || row.start_section < 1 || row.end_section > 12 || row.start_section > row.end_section) {
errors.push(`${index + 1} 行:节次范围非法 (1-12)`)
}
if (!['all', 'odd', 'even'].includes(row.week_type)) {
errors.push(`${index + 1} 行:周类型非法`)
}
})
return errors
}
const handleImport = async () => {
const errors = validateRows()
if (errors.length > 0) {
ElMessage.error({
message: errors[0], // 只显示第一个错误
duration: 3000
})
return
}
try {
await ElMessageBox.confirm('确定要导入识别出的课程吗?这可能会覆盖或冲突现有日程。', '确认导入', {
confirmButtonText: '确定导入',
cancelButtonText: '我再想想',
type: 'info'
})
importLoading.value = true
const payload = buildPayload(rows.value)
await importCourses(payload)
ElMessage.success('课程导入成功!')
visible.value = false
emit('success')
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error.message || '导入失败')
}
} finally {
importLoading.value = false
}
}
const buildPayload = (draftRows: CourseDraftRow[]): CourseImportPayload => {
const grouped = new Map<string, CourseImportPayload['courses'][number]>()
for (const row of draftRows) {
// 恢复原来的聚合逻辑:以 课程名 + 地点 + 是否允许任务 为主键
const key = `${row.course_name.trim()}__${row.location.trim()}__${row.is_allow_tasks}`
const arrangement = {
start_week: row.start_week!,
end_week: row.end_week!,
day_of_week: row.day_of_week!,
start_section: row.start_section!,
end_section: row.end_section!,
week_type: row.week_type as 'all' | 'odd' | 'even'
}
if (!grouped.has(key)) {
grouped.set(key, {
course_name: row.course_name.trim(),
location: row.location.trim(),
is_allow_tasks: row.is_allow_tasks,
arrangements: [arrangement]
})
} else {
grouped.get(key)!.arrangements.push(arrangement)
}
}
return { courses: Array.from(grouped.values()) }
}
const getRowStyle = ({ row }: { row: CourseDraftRow }) => {
if (row.confidence < 0.75 || row.row_warnings.length > 0) {
return { backgroundColor: 'rgba(245, 158, 11, 0.05)' }
}
return {}
}
</script>
<template>
<el-dialog
v-model="visible"
title="导入课表"
width="98%"
top="2vh"
class="import-dialog"
:close-on-click-modal="false"
destroy-on-close
@closed="reset"
>
<!-- 阶段一上传识别 -->
<div v-if="step === 'upload'" class="upload-stage">
<div class="stage-header">
<h3>1. 上传课表截图</h3>
<p>请上传完整的课表图片AI 将自动识别课程信息</p>
<div class="upload-tips">
<span class="tip-item"> 预计识别时长约 2 分钟</span>
<span class="tip-item">🤖 结果由 AI 生成请务必仔细审核</span>
</div>
</div>
<div class="upload-container">
<div class="example-box">
<span class="badge">截图示例</span>
<img :src="exampleImageUrl" alt="Example" class="example-img" />
</div>
<div class="upload-box" :class="{ 'has-file': !!imageFile }">
<input
type="file"
id="course-upload-input"
accept="image/*"
hidden
@change="handleFileChange"
/>
<label for="course-upload-input" class="upload-trigger">
<div v-if="!imagePreview" class="placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12"/>
</svg>
<span>点击选择或拖拽图片</span>
</div>
<img v-else :src="imagePreview" alt="Preview" class="preview-img" />
</label>
</div>
</div>
<div class="stage-footer">
<button
v-if="parseLoading"
class="btn-ghost"
@click="stopRecognition"
>
停止识别
</button>
<button
class="btn-primary"
:disabled="!imageFile || parseLoading"
@click="startRecognition"
>
<span v-if="parseLoading" class="spinner"></span>
{{ parseLoading ? '正在识别中...' : '开始识别' }}
</button>
</div>
</div>
<!-- 阶段二核对修改 -->
<div v-else class="edit-stage">
<div class="stage-header">
<div class="header-left">
<h3>2. 核对识别结果</h3>
<p>{{ draftMessage }}</p>
</div>
<div class="header-actions">
<button class="btn-ghost btn-sm" @click="handleOpenAddDialog">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px;margin-right:4px;">
<path d="M12 5v14M5 12h14"/>
</svg>
新增课程行
</button>
</div>
<div v-if="warnings.length > 0" class="warning-banner">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-icon">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0zM12 9v4M12 17h.01"/>
</svg>
<div class="w-list">
<div v-for="(w, i) in warnings" :key="i" class="w-item">{{ w }}</div>
</div>
</div>
</div>
<div class="table-container">
<el-table
:data="rows"
style="width: 100%"
:row-style="getRowStyle"
max-height="500px"
class="flat-table"
>
<el-table-column width="50" align="center">
<template #default="{ $index }">
<div
class="drag-handle"
draggable="true"
@dragstart="onDragStart($index)"
@dragover="onDragOver"
@drop="onDrop($index)"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 8h16M4 16h16"/>
</svg>
</div>
</template>
</el-table-column>
<el-table-column label="课程名" min-width="150">
<template #default="{ row }">
<el-input v-model="row.course_name" placeholder="请输入" />
</template>
</el-table-column>
<el-table-column label="地点" min-width="120">
<template #default="{ row }">
<el-input v-model="row.location" placeholder="地点" />
</template>
</el-table-column>
<el-table-column label="周次" width="180">
<template #default="{ row }">
<div class="week-range">
<el-input-number v-model="row.start_week" :min="1" :max="24" controls-position="right" size="small" />
<span>-</span>
<el-input-number v-model="row.end_week" :min="1" :max="24" controls-position="right" size="small" />
</div>
</template>
</el-table-column>
<el-table-column label="星期" width="100">
<template #default="{ row }">
<el-select v-model="row.day_of_week" size="small">
<el-option v-for="i in 7" :key="i" :label="'周' + ['一','二','三','四','五','六','日'][i-1]" :value="i" />
</el-select>
</template>
</el-table-column>
<el-table-column label="节次" width="160">
<template #default="{ row }">
<div class="section-range">
<el-input-number v-model="row.start_section" :min="1" :max="12" controls-position="right" size="small" />
<span>-</span>
<el-input-number v-model="row.end_section" :min="1" :max="12" controls-position="right" size="small" />
</div>
</template>
</el-table-column>
<el-table-column label="类型" width="100">
<template #default="{ row }">
<el-select v-model="row.week_type" size="small">
<el-option label="每周" value="all" />
<el-option label="单周" value="odd" />
<el-option label="双周" value="even" />
</el-select>
</template>
</el-table-column>
<el-table-column label="允许嵌入" width="100" align="center">
<template #default="{ row }">
<el-switch v-model="row.is_allow_tasks" />
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<div v-if="row.row_warnings.length > 0" class="row-warning-dot">
<el-tooltip :content="row.row_warnings.join('; ')" placement="top">
<span class="warning-icon"></span>
</el-tooltip>
</div>
<div v-else-if="row.confidence < 0.75" class="row-warning-dot">
<el-tooltip content="置信度较低,请人工核对" placement="top">
<span class="info-icon">💡</span>
</el-tooltip>
</div>
<span v-else class="success-dot"></span>
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center" fixed="right">
<template #default="{ $index }">
<button class="btn-icon-danger" @click="handleRemoveRow($index)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</template>
</el-table-column>
</el-table>
</div>
<div class="stage-footer">
<button class="btn-ghost" @click="step = 'upload'">重新上传</button>
<button
class="btn-primary"
:disabled="importLoading"
@click="handleImport"
>
<span v-if="importLoading" class="spinner"></span>
{{ importLoading ? '正在导入...' : '导入课程' }}
</button>
</div>
</div>
<!-- 新增课程子弹窗 -->
<el-dialog
v-model="addDialogVisible"
title="手动新增课程"
width="500px"
append-to-body
class="sub-dialog"
>
<div class="add-row-form">
<div class="form-item">
<label>课程名</label>
<el-input v-model="newRowData.course_name" placeholder="例如:高等数学" />
</div>
<div class="form-item">
<label>地点</label>
<el-input v-model="newRowData.location" placeholder="例如教一203" />
</div>
<div class="form-row">
<div class="form-item">
<label>周次范围</label>
<div class="range-inputs">
<el-input-number v-model="newRowData.start_week" :min="1" :max="24" controls-position="right" />
<span></span>
<el-input-number v-model="newRowData.end_week" :min="1" :max="24" controls-position="right" />
</div>
</div>
</div>
<div class="form-row">
<div class="form-item">
<label>星期</label>
<el-select v-model="newRowData.day_of_week" style="width: 100%">
<el-option v-for="i in 7" :key="i" :label="'周' + ['一','二','三','四','五','六','日'][i-1]" :value="i" />
</el-select>
</div>
<div class="form-item">
<label>周类型</label>
<el-select v-model="newRowData.week_type" style="width: 100%">
<el-option label="每周" value="all" />
<el-option label="单周" value="odd" />
<el-option label="双周" value="even" />
</el-select>
</div>
</div>
<div class="form-item">
<label>节次范围</label>
<div class="range-inputs">
<el-input-number v-model="newRowData.start_section" :min="1" :max="12" controls-position="right" />
<span></span>
<el-input-number v-model="newRowData.end_section" :min="1" :max="12" controls-position="right" />
</div>
</div>
<div class="form-item flex-row">
<label>允许在该时段嵌入任务</label>
<el-switch v-model="newRowData.is_allow_tasks" />
</div>
</div>
<template #footer>
<div class="dialog-footer">
<button class="btn-ghost" @click="addDialogVisible = false">取消</button>
<button class="btn-primary" @click="handleConfirmAddRow">确定添加</button>
</div>
</template>
</el-dialog>
</el-dialog>
</template>
<style scoped>
.import-dialog :deep(.el-dialog) {
border-radius: 24px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(15, 23, 42, 0.15);
}
.import-dialog :deep(.el-dialog__header) {
padding: 24px 24px 0;
margin-right: 0;
}
.import-dialog :deep(.el-dialog__title) {
font-weight: 800;
color: #1e293b;
font-size: 20px;
}
.stage-header {
padding: 0 24px 20px;
}
.stage-header h3 {
margin: 0 0 6px;
font-size: 18px;
color: #1e293b;
}
.stage-header p {
margin: 0;
font-size: 14px;
color: #64748b;
}
.upload-tips {
margin-top: 12px;
display: flex;
gap: 16px;
}
.tip-item {
font-size: 12px;
color: #94a3b8;
display: flex;
align-items: center;
gap: 4px;
background: #f8fafc;
padding: 4px 10px;
border-radius: 6px;
border: 1px solid #f1f5f9;
}
.upload-container {
padding: 0 24px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
.example-box {
position: relative;
background: #f8fafc;
border-radius: 16px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed #e2e8f0;
height: 550px;
}
.example-box .badge {
position: absolute;
top: 12px;
left: 12px;
background: rgba(15, 23, 42, 0.6);
color: white;
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
backdrop-filter: blur(4px);
}
.example-img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
opacity: 1;
}
.upload-box {
background: #ffffff;
border: 2px dashed #e2e8f0;
border-radius: 16px;
transition: all 0.2s ease;
cursor: pointer;
overflow: hidden;
height: 550px;
}
.upload-box:hover {
border-color: #3b82f6;
background: #f0f7ff;
}
.upload-box.has-file {
border-style: solid;
}
.upload-trigger {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
}
.upload-trigger .placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.upload-trigger svg {
width: 40px;
height: 40px;
color: #94a3b8;
margin-bottom: 12px;
}
.upload-trigger span {
font-size: 14px;
color: #64748b;
}
.preview-img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.stage-footer {
padding: 24px;
display: flex;
justify-content: flex-end;
gap: 12px;
}
.btn-primary {
height: 44px;
padding: 0 24px;
background: #3b82f6;
color: white;
border: none;
border-radius: 12px;
font-weight: 700;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
transition: all 0.2s ease;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2);
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(37, 99, 235, 0.3);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-ghost {
height: 44px;
padding: 0 24px;
background: #f1f5f9;
color: #475569;
border: none;
border-radius: 12px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-ghost:hover {
background: #e2e8f0;
color: #1e293b;
}
.warning-banner {
margin-top: 12px;
background: #fffbeb;
border: 1px solid #fef3c7;
border-radius: 12px;
padding: 12px 16px;
display: flex;
gap: 12px;
align-items: flex-start;
}
.w-icon {
width: 20px;
height: 20px;
color: #d97706;
flex-shrink: 0;
}
.w-list {
font-size: 13px;
color: #92400e;
line-height: 1.5;
}
.table-container {
padding: 0 24px;
}
.flat-table :deep(.el-table__header-wrapper) th {
background: #f8fafc;
color: #475569;
font-weight: 700;
border-bottom: 1px solid #f1f5f9;
}
.flat-table :deep(.el-table__row) td {
border-bottom: 1px solid #f1f5f9;
padding: 8px 0;
}
.week-range, .section-range {
display: flex;
align-items: center;
gap: 4px;
}
.week-range span, .section-range span {
color: #94a3b8;
}
.success-dot {
display: inline-block;
width: 8px;
height: 8px;
background: #22c55e;
border-radius: 50%;
}
.row-warning-dot {
cursor: help;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: #ffffff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.btn-icon-danger {
background: transparent;
border: none;
color: #ef4444;
cursor: pointer;
padding: 4px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.btn-icon-danger:hover {
background: #fee2e2;
}
.btn-icon-danger svg {
width: 18px;
height: 18px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
:deep(.el-input__wrapper), :deep(.el-input-number), :deep(.el-select) {
border-radius: 8px !important;
box-shadow: none !important;
border: 1px solid #e2e8f0 !important;
}
:deep(.el-input__wrapper.is-focus) {
border-color: #3b82f6 !important;
}
.drag-handle {
cursor: grab;
color: #94a3b8;
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
border-radius: 4px;
transition: all 0.2s ease;
}
.drag-handle:hover {
background: #f1f5f9;
color: #64748b;
}
.drag-handle svg {
width: 16px;
height: 16px;
}
.drag-handle:active {
cursor: grabbing;
}
.header-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 12px;
}
.btn-sm {
height: 32px;
padding: 0 12px;
font-size: 13px;
border-radius: 8px;
}
.add-row-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-item {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-item label {
font-size: 13px;
font-weight: 600;
color: #64748b;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.range-inputs {
display: flex;
align-items: center;
gap: 12px;
}
.range-inputs span {
color: #94a3b8;
font-size: 13px;
}
.flex-row {
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

View File

@@ -64,6 +64,54 @@ const eventLookup = computed(() => {
return map
})
// isFullyCovered 负责判断一个 Slot (2节课) 是否被之前的跨行事件完全遮挡。
// 如果只是部分遮挡(如 span=3 占用了下一 Slot 的第一节),则不视为完全遮挡,允许渲染以维持布局完整性。
function isFullyCovered(dayOfWeek: number, order: number) {
const endSectionOfOrder = order * 2
for (let prevOrder = 1; prevOrder < order; prevOrder++) {
const event = resolveEvent(dayOfWeek, prevOrder)
if (event && event.type !== 'empty') {
const eventEndSection = (prevOrder - 1) * 2 + (event.span || 2)
if (eventEndSection >= endSectionOfOrder) {
return true
}
}
}
return false
}
// resolveGridRow 负责计算格子的精确 Grid 布局位置。
// 特别处理了“部分遮挡”的情况:如果一个空格子被上方的长课时占用了第一节,则该格子会自动下移并缩短,从而紧贴在课程下方。
function resolveGridRow(dayOfWeek: number, order: number) {
const event = resolveEvent(dayOfWeek, order)
const startRow = (order - 1) * 2 + 2 // 该 Slot 默认的起始行Header 占 1 行,每节课 1 行)
// 1. 如果是真实课程/任务,遵循其原始 order 对应的起点,并按其实际 span 拉伸。
if (event && event.type !== 'empty') {
return `${startRow} / span ${event.span || 2}`
}
// 2. 如果是空格子,检查上方是否有跨行事件侵入了当前 Slot。
let currentStartRow = startRow
const currentEndRow = startRow + 2 // 一个 Slot 默认占 2 行
for (let prevOrder = 1; prevOrder < order; prevOrder++) {
const prevEvent = resolveEvent(dayOfWeek, prevOrder)
if (prevEvent && prevEvent.type !== 'empty') {
// 计算上方事件在网格中的结束行
const prevEndRow = (prevOrder - 1) * 2 + 2 + (prevEvent.span || 2)
// 如果上方事件的结束行超过了当前格子的起始行,则需要将起点下移
if (prevEndRow > currentStartRow) {
currentStartRow = prevEndRow
}
}
}
const finalSpan = currentEndRow - currentStartRow
return `${currentStartRow} / span ${Math.max(0, finalSpan)}`
}
function resolveEvent(dayOfWeek: number, order: number) {
return eventLookup.value.get(`${dayOfWeek}-${order}`)
}
@@ -346,21 +394,30 @@ function handlePreviewDragEnd() {
</header>
<div class="planning-board__grid" @dragover="handleExternalDragOver">
<div class="planning-board__corner" />
<div class="planning-board__corner" style="grid-row: 1; grid-column: 1;" />
<div v-for="header in weekHeaders" :key="header.dayOfWeek" class="planning-board__day-head">
<div
v-for="header in weekHeaders"
:key="header.dayOfWeek"
class="planning-board__day-head"
:style="{ gridRow: 1, gridColumn: header.dayOfWeek + 1 }"
>
<span>{{ header.label }}</span>
<small>{{ header.dateLabel }}</small>
</div>
<template v-for="slot in sectionSlots" :key="slot.order">
<div class="planning-board__time-cell">
<div
class="planning-board__time-cell"
:style="{ gridRow: `${(slot.order - 1) * 2 + 2} / span 2`, gridColumn: 1 }"
>
<strong>{{ slot.title }}</strong>
<small>{{ slot.timeRange }}</small>
</div>
<article
v-for="header in weekHeaders"
v-show="!isFullyCovered(header.dayOfWeek, slot.order)"
:key="`${weekData?.week ?? 0}-${header.dayOfWeek}-${slot.order}`"
class="planning-board__cell"
:class="[
@@ -375,7 +432,12 @@ function handlePreviewDragEnd() {
'planning-board__cell--dragover': dragOverCellKey === buildCellKey(header.dayOfWeek, slot.order),
},
]"
:style="{ '--anim-delay': (header.dayOfWeek - 1) * 0.035 + (slot.order - 1) * 0.045 + 's' }"
:style="{
'--anim-delay': (header.dayOfWeek - 1) * 0.035 + (slot.order - 1) * 0.045 + 's',
gridRow: resolveGridRow(header.dayOfWeek, slot.order),
gridColumn: header.dayOfWeek + 1,
zIndex: (resolveEvent(header.dayOfWeek, slot.order) && resolveEvent(header.dayOfWeek, slot.order)!.type !== 'empty') ? 2 : 1
}"
:draggable="isWholeCellDraggable(resolveEvent(header.dayOfWeek, slot.order))"
@dragstart="handlePreviewDragStart(header.dayOfWeek, slot.order, $event)"
@dragover="handlePreviewDragOver(header.dayOfWeek, slot.order, $event)"
@@ -467,6 +529,7 @@ function handlePreviewDragEnd() {
--planning-time-column-width: 68px;
--planning-day-column-min: 96px;
--planning-cell-height: clamp(72px, 9.2vh, 112px);
--planning-section-height: calc(var(--planning-cell-height) / 2);
min-width: 0;
min-height: 0;
border-radius: 20px;
@@ -490,6 +553,7 @@ function handlePreviewDragEnd() {
min-height: 0;
display: grid;
grid-template-columns: var(--planning-time-column-width) repeat(7, minmax(var(--planning-day-column-min), 1fr));
grid-template-rows: auto repeat(12, var(--planning-section-height));
gap: var(--planning-grid-gap-y) var(--planning-grid-gap-x);
padding: var(--planning-grid-padding-y) var(--planning-grid-padding-x) 24px;
overflow: auto;
@@ -518,7 +582,7 @@ function handlePreviewDragEnd() {
}
.planning-board__time-cell {
min-height: var(--planning-cell-height);
min-height: 0;
display: grid;
align-content: center;
justify-items: end;
@@ -541,7 +605,7 @@ function handlePreviewDragEnd() {
.planning-board__cell {
position: relative;
min-height: var(--planning-cell-height);
min-height: 0;
border-radius: 14px;
border: 1px solid transparent;
padding: 14px;
@@ -857,7 +921,7 @@ function handlePreviewDragEnd() {
.planning-board__time-cell,
.planning-board__cell {
min-height: 98px;
min-height: 0;
}
.planning-board__cell {